diff --git a/.env.example b/.env.example index f38a881..9974db3 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,20 @@ BACKEND_PORT=8001 REDIS_PORT=6379 +# ── AUTH ───────────────────────────────────────────────────────────────────── + +# Secret key used to sign JWT tokens. Generate a strong random value for +# production (e.g. `openssl rand -base64 32`). The default is a deterministic +# placeholder acceptable only for local/homelab use. +# SECRET_KEY=change-me-to-a-random-string + +# How long access and refresh tokens stay valid. Access tokens are short-lived +# and silently refreshed by the frontend; refresh tokens let a session survive +# across browser restarts. +# ACCESS_TOKEN_EXPIRE_MINUTES=60 +# REFRESH_TOKEN_EXPIRE_DAYS=30 + + # ── CORS ───────────────────────────────────────────────────────────────────── # Comma-separated list of allowed origins for direct browser access to the diff --git a/backend/alembic/versions/0009_users_and_auth.py b/backend/alembic/versions/0009_users_and_auth.py new file mode 100644 index 0000000..1948233 --- /dev/null +++ b/backend/alembic/versions/0009_users_and_auth.py @@ -0,0 +1,144 @@ +"""users table and user_id foreign keys + +Revision ID: 0009_users_and_auth +Revises: 0008_photos_date_warning +Create Date: 2026-04-12 + +Introduces multi-user support: + 1. Creates the `users` table. + 2. Adds `user_id` FK columns to photos, folders, source_roots, heaps, tags. + 3. For existing installs: creates a default admin user and assigns all + existing rows to that user. The generated password is printed to the + backend logs — the admin should change it on first login. + 4. Replaces the unique constraint on tags (name, kind) with + (name, kind, user_id) so each user can have their own tags. +""" +from typing import Sequence, Union +import uuid +import secrets + +from alembic import op +import sqlalchemy as sa + +revision: str = "0009_users_and_auth" +down_revision: Union[str, None] = "0008_photos_date_warning" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # 1. Create users table (IF NOT EXISTS — safe on fresh installs where + # init_db's create_all has already laid down the schema). + conn.execute(sa.text(""" + CREATE TABLE IF NOT EXISTS users ( + id VARCHAR NOT NULL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR UNIQUE, + hashed_password VARCHAR NOT NULL, + role VARCHAR NOT NULL DEFAULT 'user', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(), + media_path VARCHAR NOT NULL + ) + """)) + conn.execute(sa.text( + "CREATE INDEX IF NOT EXISTS ix_users_username ON users (username)" + )) + + # 2. Add user_id columns (nullable initially for the data migration) + for table in ("photos", "folders", "source_roots", "heaps", "tags"): + conn.execute(sa.text( + f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS user_id VARCHAR" + )) + conn.execute(sa.text( + f"CREATE INDEX IF NOT EXISTS ix_{table}_user_id ON {table} (user_id)" + )) + # FK — check if it already exists before adding + fk_name = f"fk_{table}_user_id" + fk_exists = conn.execute(sa.text( + "SELECT 1 FROM information_schema.table_constraints " + "WHERE constraint_name = :name AND table_name = :tbl" + ), {"name": fk_name, "tbl": table}).scalar() + if not fk_exists: + conn.execute(sa.text( + f"ALTER TABLE {table} ADD CONSTRAINT {fk_name} " + f"FOREIGN KEY (user_id) REFERENCES users(id)" + )) + + # 3. Data migration: if rows exist, create a default admin and assign + conn = op.get_bind() + photo_count = conn.execute(sa.text("SELECT COUNT(*) FROM photos")).scalar() + + if photo_count > 0: + admin_id = str(uuid.uuid4()) + generated_password = secrets.token_urlsafe(16) + + # Hash the password using passlib at migration time + from passlib.context import CryptContext + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + hashed = pwd_context.hash(generated_password) + + # The default admin's media_path is the legacy /photos root + conn.execute( + sa.text( + "INSERT INTO users (id, username, hashed_password, role, media_path) " + "VALUES (:id, :username, :hashed, :role, :media_path)" + ), + { + "id": admin_id, + "username": "admin", + "hashed": hashed, + "role": "admin", + "media_path": "/photos", + }, + ) + + # Assign all existing rows to the default admin + for table in ("photos", "folders", "source_roots", "heaps", "tags"): + conn.execute( + sa.text(f"UPDATE {table} SET user_id = :uid WHERE user_id IS NULL"), + {"uid": admin_id}, + ) + + import logging + logger = logging.getLogger("alembic.migration") + logger.warning( + f"=== MIGRATION 0009 === Default admin created. " + f"Username: admin | Password: {generated_password} | " + f"Change this password on first login!" + ) + + # 4. Replace tag unique constraint to include user_id + # Check whether the old constraint exists before trying to drop it + # (on fresh installs create_all creates the new constraint directly). + old_uq_exists = conn.execute(sa.text( + "SELECT 1 FROM information_schema.table_constraints " + "WHERE constraint_name = 'uq_tags_name_kind' AND table_name = 'tags'" + )).scalar() + if old_uq_exists: + op.drop_constraint("uq_tags_name_kind", "tags", type_="unique") + + new_uq_exists = conn.execute(sa.text( + "SELECT 1 FROM information_schema.table_constraints " + "WHERE constraint_name = 'uq_tags_name_kind_user' AND table_name = 'tags'" + )).scalar() + if not new_uq_exists: + op.create_unique_constraint("uq_tags_name_kind_user", "tags", ["name", "kind", "user_id"]) + + +def downgrade() -> None: + # Reverse the tag constraint + op.drop_constraint("uq_tags_name_kind_user", "tags", type_="unique") + op.create_unique_constraint("uq_tags_name_kind", "tags", ["name", "kind"]) + + # Drop user_id columns and FKs + for table in ("photos", "folders", "source_roots", "heaps", "tags"): + op.drop_constraint(f"fk_{table}_user_id", table, type_="foreignkey") + op.drop_index(f"ix_{table}_user_id", table_name=table) + op.drop_column(table, "user_id") + + # Drop users table + op.drop_index("ix_users_username", table_name="users") + op.drop_table("users") diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..34050d4 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,47 @@ +""" +Authentication utilities — password hashing and JWT token management. +""" +from datetime import datetime, timedelta, timezone + +from jose import jwt, JWTError +from passlib.context import CryptContext + +from app.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(user_id: str, role: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) + payload = { + "sub": user_id, + "role": role, + "exp": expire, + "type": "access", + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def create_refresh_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days) + payload = { + "sub": user_id, + "exp": expire, + "type": "refresh", + } + return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict: + """Decode and validate a JWT. Raises JWTError on any problem.""" + return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) diff --git a/backend/app/config.py b/backend/app/config.py index 025bd6f..d8308ef 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -127,6 +127,17 @@ class Settings(BaseSettings): # ERROR, CRITICAL). Bumped from INFO when chasing a problem. log_level: str = Field(default="INFO", env="LOG_LEVEL") + # Auth — JWT signing key. Set SECRET_KEY in .env for production. + # If unset, a deterministic fallback is used (acceptable for + # single-machine homelab deploys, but set a real key if the instance + # is network-exposed). + secret_key: str = Field( + default="mulita-dev-secret-change-me", + env="SECRET_KEY", + ) + access_token_expire_minutes: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES") + refresh_token_expire_days: int = Field(default=30, env="REFRESH_TOKEN_EXPIRE_DAYS") + @property def cors_origins(self) -> list[str]: """Parse the ALLOWED_ORIGINS env var into a list. Accepts: diff --git a/backend/app/database.py b/backend/app/database.py index 32211a8..670571b 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -100,7 +100,7 @@ async def init_db(): """Initialize database, create tables if they don't exist""" async with engine.begin() as conn: # Import all models to register them with Base - from app.models import Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding + from app.models import User, Photo, Folder, SourceRoot, Tag, PhotoTag, Heap, HeapPhoto, Embedding # Postgres: ensure pgvector is available before create_all touches # any Vector columns (added in later PRs but the extension is cheap diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..001fbab --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,132 @@ +""" +FastAPI dependencies for authentication and user-scoped data access. +""" +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.auth import decode_token +from app.database import get_db +from app.models.user import User +from app.models.photos import Photo +from app.models.folders import Folder, SourceRoot +from app.models.heaps import Heap +from app.models.tags import Tag + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + """Decode JWT, look up user, raise 401 if invalid or inactive.""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_token(token) + user_id: str = payload.get("sub") + token_type: str = payload.get("type") + if user_id is None or token_type != "access": + raise credentials_exception + except JWTError: + raise credentials_exception + + 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 credentials_exception + return user + + +async def require_admin( + user: User = Depends(get_current_user), +) -> User: + """Raise 403 if user is not an admin.""" + if user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required", + ) + return user + + +# --------------------------------------------------------------------------- +# User-scoped query helpers +# --------------------------------------------------------------------------- + +def user_photos_query(user: User): + """Base select for photos owned by user, with tags eager-loaded.""" + return ( + select(Photo) + .options(selectinload(Photo.tags)) + .where(Photo.user_id == user.id) + ) + + +async def get_user_photo( + photo_id: str, + user: User, + db: AsyncSession, +) -> Photo: + """Fetch a single photo by ID, scoped to the user. Raises 404.""" + result = await db.execute( + select(Photo) + .options(selectinload(Photo.tags)) + .where(Photo.id == photo_id, Photo.user_id == user.id) + ) + photo = result.scalar_one_or_none() + if photo is None: + raise HTTPException(status_code=404, detail="Photo not found") + return photo + + +async def get_user_folder( + folder_id: str, + user: User, + db: AsyncSession, +) -> Folder: + """Fetch a single folder by ID, scoped to the user. Raises 404.""" + result = await db.execute( + select(Folder).where(Folder.id == folder_id, Folder.user_id == user.id) + ) + folder = result.scalar_one_or_none() + if folder is None: + raise HTTPException(status_code=404, detail="Folder not found") + return folder + + +async def get_user_heap( + heap_id: str, + user: User, + db: AsyncSession, +) -> Heap: + """Fetch a single heap by ID, scoped to the user. Raises 404.""" + result = await db.execute( + select(Heap).where(Heap.id == heap_id, Heap.user_id == user.id) + ) + heap = result.scalar_one_or_none() + if heap is None: + raise HTTPException(status_code=404, detail="Heap not found") + return heap + + +async def get_user_tag( + tag_id: str, + user: User, + db: AsyncSession, +) -> Tag: + """Fetch a single tag by ID, scoped to the user. Raises 404.""" + result = await db.execute( + select(Tag).where(Tag.id == tag_id, Tag.user_id == user.id) + ) + tag = result.scalar_one_or_none() + if tag is None: + raise HTTPException(status_code=404, detail="Tag not found") + return tag diff --git a/backend/app/main.py b/backend/app/main.py index 4ceaa66..cecc468 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ import os from app.config import settings from app.database import init_db -from app.routers import photos, folders, heaps, tags, discard, library, search +from app.routers import photos, folders, heaps, tags, discard, library, search, auth, admin from app.services.scanner import start_initial_scan, bootstrap_default_source_root from app.services.cleanup import cleanup_data_integrity @@ -85,6 +85,8 @@ if os.path.exists("/data/thumbs"): app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs") # Include routers +app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) +app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(photos.router, prefix="/api/v1/photos", tags=["photos"]) app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8d45771..945cc70 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ """ Database models for Mulita """ +from app.models.user import User from app.models.photos import Photo from app.models.folders import Folder, SourceRoot from app.models.tags import Tag, PhotoTag @@ -10,6 +11,7 @@ from app.models.ocr_text import OCRText from app.models.face_embedding import FaceEmbedding __all__ = [ + 'User', 'Photo', 'Folder', 'SourceRoot', diff --git a/backend/app/models/folders.py b/backend/app/models/folders.py index 51cfb6b..036a057 100644 --- a/backend/app/models/folders.py +++ b/backend/app/models/folders.py @@ -10,13 +10,16 @@ from app.database import Base class SourceRoot(Base): __tablename__ = 'source_roots' - + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) name = Column(String, nullable=False) path = Column(String, unique=True, nullable=False) is_active = Column(Boolean, default=True) added_at = Column(DateTime, server_default=func.now()) - + + # Owner + user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True) + # Relationships folders = relationship("Folder", back_populates="source_root") @@ -28,6 +31,9 @@ class Folder(Base): path = Column(String, unique=True, nullable=False) parent_id = Column(String, ForeignKey('folders.id')) source_root_id = Column(String, ForeignKey('source_roots.id')) + + # Owner + user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True) photo_count = Column(Integer, default=0) last_scanned = Column(DateTime) diff --git a/backend/app/models/heaps.py b/backend/app/models/heaps.py index 470fda2..9a46aac 100644 --- a/backend/app/models/heaps.py +++ b/backend/app/models/heaps.py @@ -22,12 +22,15 @@ heap_photos = Table( class Heap(Base): __tablename__ = 'heaps' - + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) name = Column(String, nullable=False) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, onupdate=func.now()) is_active = Column(Boolean, default=False) # For active heap feature + + # Owner + user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True) # Relationships photos = relationship("Photo", secondary=heap_photos, backref="heaps") diff --git a/backend/app/models/photos.py b/backend/app/models/photos.py index e5ddf6c..df76bae 100644 --- a/backend/app/models/photos.py +++ b/backend/app/models/photos.py @@ -14,6 +14,9 @@ class Photo(Base): # Primary key id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + # Owner + user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True) + # File information filepath = Column(String, unique=True, nullable=False) filename = Column(String, nullable=False) diff --git a/backend/app/models/tags.py b/backend/app/models/tags.py index 6cb50c2..ff6fe8c 100644 --- a/backend/app/models/tags.py +++ b/backend/app/models/tags.py @@ -30,13 +30,16 @@ photo_tags = Table( class Tag(Base): __tablename__ = 'tags' __table_args__ = ( - UniqueConstraint('name', 'kind', name='uq_tags_name_kind'), + UniqueConstraint('name', 'kind', 'user_id', name='uq_tags_name_kind_user'), ) id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) name = Column(String, nullable=False, index=True) color = Column(String) # Hex color code for UI display + # Owner + user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True) + # Tag classification kind = Column(String, nullable=False, default='user', index=True) # kind values: 'user' | 'object' | 'scene' | 'face_cluster' diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..917bd53 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,23 @@ +""" +User model definition +""" +from sqlalchemy import Column, String, Boolean, DateTime +from sqlalchemy.sql import func +import uuid + +from app.database import Base + + +class User(Base): + __tablename__ = 'users' + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + username = Column(String(50), unique=True, nullable=False, index=True) + email = Column(String, unique=True, nullable=True) + hashed_password = Column(String, nullable=False) + role = Column(String, nullable=False, default='user') # 'admin' | 'user' + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, server_default=func.now()) + + # Absolute path to this user's photo directory (e.g., "/photos/daniel") + media_path = Column(String, nullable=False) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..27b04fc --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,260 @@ +""" +Admin router — user management and app configuration. +All endpoints require admin role. +""" +import os +import logging +from typing import Optional, List + +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 +from app.database import get_db +from app.dependencies import require_admin +from app.models.user import User +from app.models.photos import Photo +from app.models.folders import SourceRoot +from app.config import settings + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class CreateUserRequest(BaseModel): + username: str + password: str + role: str = "user" # 'admin' | 'user' + +class UpdateUserRequest(BaseModel): + role: Optional[str] = None + is_active: Optional[bool] = None + new_password: Optional[str] = None + +class UserDetailResponse(BaseModel): + id: str + username: str + email: Optional[str] + role: str + is_active: bool + media_path: str + created_at: Optional[str] + photo_count: int = 0 + +class UserListResponse(BaseModel): + users: List[UserDetailResponse] + total: int + + +# --------------------------------------------------------------------------- +# User CRUD +# --------------------------------------------------------------------------- + +@router.get("/users", response_model=UserListResponse) +async def list_users( + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """List all users with their photo counts.""" + result = await db.execute(select(User).order_by(User.created_at)) + users = result.scalars().all() + + user_list = [] + for u in users: + count_result = await db.execute( + select(sa_func.count(Photo.id)).where(Photo.user_id == u.id) + ) + photo_count = count_result.scalar() or 0 + + user_list.append(UserDetailResponse( + id=u.id, + username=u.username, + email=u.email, + role=u.role, + is_active=u.is_active, + media_path=u.media_path, + created_at=u.created_at.isoformat() if u.created_at else None, + photo_count=photo_count, + )) + + return UserListResponse(users=user_list, total=len(user_list)) + + +@router.post("/users", status_code=201, response_model=UserDetailResponse) +async def create_user( + body: CreateUserRequest, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Create a new user. Creates their media directory and source root.""" + if body.role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'") + 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") + + # Check for duplicate username + existing = await db.execute( + select(User).where(User.username == body.username.strip()) + ) + if existing.scalar_one_or_none() is not None: + raise HTTPException(status_code=409, detail="Username already taken") + + 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=body.role, + 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"Admin '{admin.username}' created user '{user.username}' (role={user.role})") + + return UserDetailResponse( + id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + media_path=user.media_path, + created_at=user.created_at.isoformat() if user.created_at else None, + photo_count=0, + ) + + +@router.get("/users/{user_id}", response_model=UserDetailResponse) +async def get_user( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Get a single user's details.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + count_result = await db.execute( + select(sa_func.count(Photo.id)).where(Photo.user_id == user.id) + ) + photo_count = count_result.scalar() or 0 + + return UserDetailResponse( + id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + media_path=user.media_path, + created_at=user.created_at.isoformat() if user.created_at else None, + photo_count=photo_count, + ) + + +@router.patch("/users/{user_id}", response_model=UserDetailResponse) +async def update_user( + user_id: str, + body: UpdateUserRequest, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Update a user's role, active status, or password.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + if body.role is not None: + if body.role not in ("admin", "user"): + raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'") + # Prevent demoting the last admin + if user.role == "admin" and body.role == "user": + admin_count = (await db.execute( + select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True) + )).scalar() + if admin_count <= 1: + raise HTTPException(status_code=400, detail="Cannot demote the last admin") + user.role = body.role + + if body.is_active is not None: + # Prevent deactivating the last admin + if user.role == "admin" and not body.is_active: + admin_count = (await db.execute( + select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True) + )).scalar() + if admin_count <= 1: + raise HTTPException(status_code=400, detail="Cannot deactivate the last admin") + user.is_active = body.is_active + + if body.new_password is not None: + if len(body.new_password) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + user.hashed_password = hash_password(body.new_password) + + await db.commit() + + count_result = await db.execute( + select(sa_func.count(Photo.id)).where(Photo.user_id == user.id) + ) + photo_count = count_result.scalar() or 0 + + return UserDetailResponse( + id=user.id, + username=user.username, + email=user.email, + role=user.role, + is_active=user.is_active, + media_path=user.media_path, + created_at=user.created_at.isoformat() if user.created_at else None, + photo_count=photo_count, + ) + + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: str, + admin: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """Soft-delete a user by deactivating them. Media is preserved.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + if user.id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + # Prevent deleting the last admin + if user.role == "admin": + admin_count = (await db.execute( + select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True) + )).scalar() + if admin_count <= 1: + raise HTTPException(status_code=400, detail="Cannot delete the last admin") + + user.is_active = False + await db.commit() + + logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'") + return {"status": "ok", "detail": f"User '{user.username}' deactivated"} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..86a60f0 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,200 @@ +""" +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} diff --git a/backend/app/routers/discard.py b/backend/app/routers/discard.py index b5c0c2d..15bdd0d 100644 --- a/backend/app/routers/discard.py +++ b/backend/app/routers/discard.py @@ -9,25 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Photo +from app.models.user import User +from app.dependencies import get_current_user logger = logging.getLogger(__name__) router = APIRouter() @router.get("") -async def list_discarded(db: AsyncSession = Depends(get_db)): +async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """List discarded photos""" result = await db.execute( - select(Photo).where(Photo.is_discarded == True) + select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id) ) photos = result.scalars().all() return photos @router.post("/restore") -async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)): +async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Restore photos from the discard pile""" result = await db.execute( - select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)) + select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)) ) photos = result.scalars().all() @@ -39,12 +41,12 @@ async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db return {"status": "success", "restored": len(photos)} @router.delete("/empty") -async def empty_discard(db: AsyncSession = Depends(get_db)): +async def empty_discard(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Permanently delete all discarded photos and unlink their files from disk. Failures on individual files are logged but don't abort the batch. """ result = await db.execute( - select(Photo).where(Photo.is_discarded == True) + select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id) ) photos = result.scalars().all() return await _permanently_delete(db, photos) @@ -54,6 +56,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db)): async def delete_discarded( photo_ids: list[str] = Body(..., embed=True), db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Permanently delete a specific subset of discarded photos. The photos must already be in the discard pile — non-discarded ids are skipped so @@ -63,7 +66,7 @@ async def delete_discarded( return {"status": "success", "deleted": 0, "file_errors": 0} result = await db.execute( select(Photo).where( - and_(Photo.id.in_(photo_ids), Photo.is_discarded == True) + and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id) ) ) photos = result.scalars().all() diff --git a/backend/app/routers/folders.py b/backend/app/routers/folders.py index 29dc2e8..81fcb8b 100644 --- a/backend/app/routers/folders.py +++ b/backend/app/routers/folders.py @@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Folder, SourceRoot, Photo +from app.models.user import User +from app.dependencies import get_current_user, get_user_folder logger = logging.getLogger(__name__) @@ -48,10 +50,10 @@ def _validate_folder_name(name: str) -> str: return name @router.get("") -async def get_folders(db: AsyncSession = Depends(get_db)): +async def get_folders(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Get all source folders""" # Get source roots instead of regular folders - result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True)) + result = await db.execute(select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id)) source_roots = result.scalars().all() folders_list = [] @@ -73,7 +75,7 @@ async def get_folders(db: AsyncSession = Depends(get_db)): return {"folders": folders_list} @router.get("/tree") -async def get_folder_tree(db: AsyncSession = Depends(get_db)): +async def get_folder_tree(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Recursive folder tree, one root per active SourceRoot. The tree starts at the Folder row matching the SourceRoot.path (the scanner creates one for every walked directory), with the SourceRoot's @@ -97,7 +99,7 @@ async def get_folder_tree(db: AsyncSession = Depends(get_db)): creates as a parent walk) are skipped via path-prefix filtering. """ sr_result = await db.execute( - select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712 + select(SourceRoot).where(SourceRoot.is_active == True, SourceRoot.user_id == current_user.id) # noqa: E712 ) source_roots = sr_result.scalars().all() @@ -196,6 +198,7 @@ async def rename_folder( folder_id: str, body: FolderRename, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Rename a folder. Two cases: @@ -212,7 +215,7 @@ async def rename_folder( # Try SourceRoot first (display-only rename). sr_result = await db.execute( - select(SourceRoot).where(SourceRoot.id == folder_id) + select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id) ) source_root = sr_result.scalar_one_or_none() if source_root: @@ -225,14 +228,11 @@ async def rename_folder( } # Otherwise it's a Folder row. - folder_result = await db.execute(select(Folder).where(Folder.id == folder_id)) - folder = folder_result.scalar_one_or_none() - if not folder: - raise HTTPException(status_code=404, detail="Folder not found") + folder = await get_user_folder(folder_id, current_user, db) # Refuse to rename the bare source root mount through here. sr_check = await db.execute( - select(SourceRoot).where(SourceRoot.id == folder.source_root_id) + select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id) ) sr = sr_check.scalar_one_or_none() if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path): @@ -291,7 +291,7 @@ async def rename_folder( @router.post("", status_code=201) -async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)): +async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Create a new sub-folder under an existing Folder. Mirrors the create to disk so the next scan sees it. Body: { name, parent_id }. parent_id MUST be an existing Folder row id (any descendant of a @@ -300,12 +300,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)): """ name = _validate_folder_name(body.name) - parent_result = await db.execute( - select(Folder).where(Folder.id == body.parent_id) - ) - parent = parent_result.scalar_one_or_none() - if not parent: - raise HTTPException(status_code=404, detail="Parent folder not found") + parent = await get_user_folder(body.parent_id, current_user, db) new_path = os.path.join(parent.path, name) if os.path.exists(new_path): @@ -323,6 +318,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)): name=name, path=new_path, source_root_id=parent.source_root_id, + user_id=current_user.id, photo_count=0, ) db.add(new_folder) @@ -341,6 +337,7 @@ async def delete_folder( folder_id: str, mode: Literal['discard', 'permanent'] = Query('discard'), db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Delete a folder. Behavior depends on mode: @@ -357,13 +354,10 @@ async def delete_folder( Refuses to delete the bare source-root mount in either mode (deleting the docker mount through the UI would be a footgun). """ - folder_result = await db.execute(select(Folder).where(Folder.id == folder_id)) - folder = folder_result.scalar_one_or_none() - if not folder: - raise HTTPException(status_code=404, detail="Folder not found") + folder = await get_user_folder(folder_id, current_user, db) sr_check = await db.execute( - select(SourceRoot).where(SourceRoot.id == folder.source_root_id) + select(SourceRoot).where(SourceRoot.id == folder.source_root_id, SourceRoot.user_id == current_user.id) ) sr = sr_check.scalar_one_or_none() if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path): @@ -484,6 +478,7 @@ async def set_folder_hidden( folder_id: str, body: FolderHide, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Toggle the "hide from views" flag on a folder or source root. @@ -502,7 +497,7 @@ async def set_folder_hidden( """ # SourceRoot path — resolve to the Folder row at the mount point. sr_result = await db.execute( - select(SourceRoot).where(SourceRoot.id == folder_id) + select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id) ) source_root = sr_result.scalar_one_or_none() @@ -511,6 +506,7 @@ async def set_folder_hidden( root_folder_result = await db.execute( select(Folder).where( Folder.source_root_id == source_root.id, + Folder.user_id == current_user.id, Folder.path == os.path.normpath(source_root.path), ) ) @@ -521,12 +517,7 @@ async def set_folder_hidden( detail="Source root has no indexed Folder row yet; scan first.", ) else: - folder_result = await db.execute( - select(Folder).where(Folder.id == folder_id) - ) - folder = folder_result.scalar_one_or_none() - if folder is None: - raise HTTPException(status_code=404, detail="Folder not found") + folder = await get_user_folder(folder_id, current_user, db) folder.is_hidden = bool(body.hidden) await db.flush() @@ -547,11 +538,11 @@ async def set_folder_hidden( @router.post("/{folder_id}/scan") -async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db)): +async def scan_folder(folder_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Trigger manual re-scan of source root folder""" from app.tasks.celery import celery_app - - result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id)) + + result = await db.execute(select(SourceRoot).where(SourceRoot.id == folder_id, SourceRoot.user_id == current_user.id)) source_root = result.scalar_one_or_none() if not source_root: diff --git a/backend/app/routers/heaps.py b/backend/app/routers/heaps.py index 142221a..ccd72ef 100644 --- a/backend/app/routers/heaps.py +++ b/backend/app/routers/heaps.py @@ -14,6 +14,8 @@ from app.database import get_db from app.models import Heap, Photo, Folder from app.models.folders import SourceRoot from app.models.heaps import heap_photos +from app.models.user import User +from app.dependencies import get_current_user, get_user_heap logger = logging.getLogger(__name__) @@ -48,7 +50,10 @@ class HeapConvertBody(BaseModel): # ── Endpoints ───────────────────────────────────────────────────────────── @router.get("") -async def list_heaps(db: AsyncSession = Depends(get_db)): +async def list_heaps( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """List all heaps with photo counts.""" # LEFT JOIN heap_photos and group so we can return counts in one query. count_subq = ( @@ -63,6 +68,7 @@ async def list_heaps(db: AsyncSession = Depends(get_db)): stmt = ( select(Heap, count_subq.c.photo_count) .outerjoin(count_subq, Heap.id == count_subq.c.heap_id) + .where(Heap.user_id == current_user.id) .order_by(Heap.created_at.asc()) ) result = await db.execute(stmt) @@ -82,12 +88,16 @@ async def list_heaps(db: AsyncSession = Depends(get_db)): @router.post("", status_code=201) -async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)): +async def create_heap( + body: HeapCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Create a new heap.""" name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="Heap name is required") - heap = Heap(name=name) + heap = Heap(name=name, user_id=current_user.id) db.add(heap) await db.commit() await db.refresh(heap) @@ -103,14 +113,14 @@ async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)): @router.patch("/{heap_id}") async def update_heap( - heap_id: str, body: HeapUpdate, db: AsyncSession = Depends(get_db) + heap_id: str, + body: HeapUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Rename a heap and/or toggle active state. Setting is_active=true on one heap deactivates all others (single-active invariant).""" - result = await db.execute(select(Heap).where(Heap.id == heap_id)) - heap = result.scalar_one_or_none() - if not heap: - raise HTTPException(status_code=404, detail="Heap not found") + heap = await get_user_heap(heap_id, current_user, db) if body.name is not None: name = body.name.strip() @@ -120,8 +130,12 @@ async def update_heap( if body.is_active is not None: if body.is_active: - # Clear active flag on all other heaps in one statement - await db.execute(update(Heap).values(is_active=False)) + # Clear active flag on all other heaps for this user + await db.execute( + update(Heap) + .where(Heap.user_id == current_user.id) + .values(is_active=False) + ) heap.is_active = True else: heap.is_active = False @@ -138,17 +152,18 @@ async def update_heap( @router.post("/{heap_id}/duplicate", status_code=201) -async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)): +async def duplicate_heap( + heap_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Create a new heap with the same membership as an existing one. The new heap is named "{original} (copy)" and is never the active target — duplicating shouldn't quietly steal the user's T-key destination. """ - result = await db.execute(select(Heap).where(Heap.id == heap_id)) - source = result.scalar_one_or_none() - if not source: - raise HTTPException(status_code=404, detail="Heap not found") + source = await get_user_heap(heap_id, current_user, db) - new_heap = Heap(name=f"{source.name} (copy)", is_active=False) + new_heap = Heap(name=f"{source.name} (copy)", is_active=False, user_id=current_user.id) db.add(new_heap) await db.flush() # populate new_heap.id without committing yet @@ -178,24 +193,30 @@ async def duplicate_heap(heap_id: str, db: AsyncSession = Depends(get_db)): @router.delete("/{heap_id}", status_code=204) -async def delete_heap(heap_id: str, db: AsyncSession = Depends(get_db)): +async def delete_heap( + heap_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Delete a heap. Photos themselves are unaffected — only the membership rows in heap_photos cascade-delete.""" - result = await db.execute(select(Heap).where(Heap.id == heap_id)) - heap = result.scalar_one_or_none() - if not heap: - raise HTTPException(status_code=404, detail="Heap not found") + heap = await get_user_heap(heap_id, current_user, db) await db.delete(heap) await db.commit() return None @router.get("/{heap_id}/photo_ids") -async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)): +async def get_heap_photo_ids( + heap_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Return just the photo ids belonging to a heap. Used by the frontend to maintain a fast client-side membership lookup for the active heap (for the basket affordance on thumbnails) without fetching full photo records.""" + await get_user_heap(heap_id, current_user, db) result = await db.execute( select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id) ) @@ -204,14 +225,14 @@ async def get_heap_photo_ids(heap_id: str, db: AsyncSession = Depends(get_db)): @router.post("/{heap_id}/photos") async def add_photos_to_heap( - heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db) + heap_id: str, + body: HeapPhotosBody, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Add photos to a heap. Idempotent: re-adding existing members is a no-op (handled by an INSERT OR IGNORE-style filter on duplicates).""" - result = await db.execute(select(Heap).where(Heap.id == heap_id)) - heap = result.scalar_one_or_none() - if not heap: - raise HTTPException(status_code=404, detail="Heap not found") + await get_user_heap(heap_id, current_user, db) if not body.photo_ids: return {"status": "success", "added": 0} @@ -241,6 +262,7 @@ async def convert_heap_to_folder( heap_id: str, body: HeapConvertBody, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Convert a heap into a folder by moving (or copying) every member photo into the target directory. Optionally deletes the heap row at @@ -249,10 +271,7 @@ async def convert_heap_to_folder( target_id may be a Folder id or a SourceRoot id (matches the /photos/move convention so the same dropdown can populate it). """ - heap_result = await db.execute(select(Heap).where(Heap.id == heap_id)) - heap = heap_result.scalar_one_or_none() - if not heap: - raise HTTPException(status_code=404, detail="Heap not found") + heap = await get_user_heap(heap_id, current_user, db) # Resolve target_id → (target_dir, target_folder) sr_check = await db.execute( @@ -402,13 +421,13 @@ async def convert_heap_to_folder( @router.delete("/{heap_id}/photos") async def remove_photos_from_heap( - heap_id: str, body: HeapPhotosBody, db: AsyncSession = Depends(get_db) + heap_id: str, + body: HeapPhotosBody, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Remove photos from a heap. Removing a non-member is a no-op.""" - result = await db.execute(select(Heap).where(Heap.id == heap_id)) - heap = result.scalar_one_or_none() - if not heap: - raise HTTPException(status_code=404, detail="Heap not found") + await get_user_heap(heap_id, current_user, db) if not body.photo_ids: return {"status": "success", "removed": 0} diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 286437d..68c1c49 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -11,42 +11,42 @@ import os import shutil from typing import List, Optional -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, Field -from sqlalchemy import select, func, update +from sqlalchemy import select, func, update, true as sa_true from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Photo +from app.models.user import User +from app.dependencies import get_current_user logger = logging.getLogger(__name__) router = APIRouter() + +def _owner_filter(user: User, scope: str | None): + """Return a column expression scoping photos to the current user, + or a pass-through true() when an admin requests global scope.""" + if scope == "global" and user.role == "admin": + return sa_true() + return Photo.user_id == user.id + # Media types we accept in the regenerate-thumbnails request body. Mirrors # the values produced by `app.tasks.scan.get_media_type`. _VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'} @router.get("/stats") -async def get_library_stats(db: AsyncSession = Depends(get_db)): - """Get library statistics + per-section counts. Each section count - matches the filter the sidebar applies when you click it, so the - sidebar badges and the timeline below them stay in sync. - - - all_photos: non-discarded photos + videos (matches the All - Photos section's default filter) - - rated: non-discarded with rating >= 1 - - colored: non-discarded with a color_label set (matches the - Colors grouped view's labeled buckets) - - duplicates: non-discarded with is_duplicate = true - - discarded: is_discarded = true - - total_size: raw bytes across every row, including discarded - """ - # Every sidebar badge runs against this filter. `not_visible` is the - # inverse: a photo is visible iff it's neither discarded nor hidden - # (marked hidden-from-views via a folder toggle). Kept as a single - # expression so every sub-count below applies it identically. - visible = (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False)) +async def get_library_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + scope: str | None = Query(None), +): + """Get library statistics. Pass ?scope=global (admin only) for + cross-user totals (used by the Settings page).""" + owner = _owner_filter(current_user, scope) + visible = owner & (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False)) all_photos_count = ( await db.execute(select(func.count(Photo.id)).where(visible)) @@ -84,7 +84,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): discarded_count = ( await db.execute( - select(func.count(Photo.id)).where(Photo.is_discarded.is_(True)) + select(func.count(Photo.id)).where(owner, Photo.is_discarded.is_(True)) ) ).scalar() or 0 @@ -92,17 +92,18 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): photo_count = ( await db.execute( select(func.count(Photo.id)).where( + owner, Photo.media_type.in_(['photo', 'heic', 'raw']) ) ) ).scalar() or 0 video_count = ( await db.execute( - select(func.count(Photo.id)).where(Photo.media_type == 'video') + select(func.count(Photo.id)).where(owner, Photo.media_type == 'video') ) ).scalar() or 0 - size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0 + size = (await db.execute(select(func.sum(Photo.file_size)).where(owner))).scalar() or 0 return { "all_photos": all_photos_count, @@ -118,7 +119,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)): } @router.post("/scan") -async def trigger_scan(): +async def trigger_scan(current_user: User = Depends(get_current_user)): """Trigger full library re-scan""" from app.tasks.scan import scan_all_source_roots @@ -128,7 +129,7 @@ async def trigger_scan(): @router.post("/backfill-gps") -async def trigger_backfill_gps(): +async def trigger_backfill_gps(current_user: User = Depends(get_current_user)): """Re-run EXIF metadata extraction on every photo that's still missing GPS coordinates. Useful after fixing the EXIF parser, or any time the Map view looks emptier than expected. Returns immediately — work runs @@ -139,7 +140,7 @@ async def trigger_backfill_gps(): return {"status": "success", "message": "GPS backfill queued"} @router.get("/scan/status") -async def get_scan_status(db: AsyncSession = Depends(get_db)): +async def get_scan_status(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Get current scan status""" import redis from app.config import settings @@ -187,20 +188,27 @@ class RegenerateThumbnailsRequest(BaseModel): @router.get("/maintenance/thumbnail-stats") -async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)): +async def get_thumbnail_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + scope: str | None = Query(None), +): """Counts of photos by processing_status, plus a media-type breakdown so the Settings panel can show the user what's outstanding.""" + owner = _owner_filter(current_user, scope) status_rows = ( await db.execute( - select(Photo.processing_status, func.count(Photo.id)).group_by( - Photo.processing_status - ) + select(Photo.processing_status, func.count(Photo.id)) + .where(owner) + .group_by(Photo.processing_status) ) ).all() media_rows = ( await db.execute( - select(Photo.media_type, func.count(Photo.id)).group_by(Photo.media_type) + select(Photo.media_type, func.count(Photo.id)) + .where(owner) + .group_by(Photo.media_type) ) ).all() @@ -222,6 +230,8 @@ async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)): async def regenerate_thumbnails( body: RegenerateThumbnailsRequest, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + scope: str | None = Query(None), ): """Reset matching photos' on-disk thumbnail directories and re-queue Celery thumbnail generation. Used by the Settings panel for the @@ -233,6 +243,8 @@ async def regenerate_thumbnails( """ from app.tasks.thumbs import generate_thumbnails + owner = _owner_filter(current_user, scope) + # Validate media_types early so a typo can't silently match nothing. media_types = body.media_types if media_types is not None: @@ -244,7 +256,7 @@ async def regenerate_thumbnails( f"Allowed: {sorted(_VALID_MEDIA_TYPES)}", } - query = select(Photo) + query = select(Photo).where(owner) if media_types: query = query.where(Photo.media_type.in_(media_types)) if body.only_failed: @@ -297,7 +309,11 @@ async def regenerate_thumbnails( @router.get("/maintenance/worker-status") -async def get_worker_status(db: AsyncSession = Depends(get_db)): +async def get_worker_status( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + scope: str | None = Query(None), +): """Diagnostics for the Celery worker fleet + recent task failures. Surfaced in the Settings panel so the user can spot a stuck queue or @@ -316,6 +332,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)): things failed without opening the DB. - broker_ok: bool — could we even reach Redis? """ + owner = _owner_filter(current_user, scope) from app.tasks.celery import celery_app from app.config import settings import redis as _redis @@ -398,7 +415,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)): # ----- Recent task failures from the photos table ---------------------- failed_total = ( await db.execute( - select(func.count(Photo.id)).where(Photo.processing_status == 'failed') + select(func.count(Photo.id)).where(owner, Photo.processing_status == 'failed') ) ).scalar() or 0 @@ -411,7 +428,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)): Photo.processing_error, Photo.updated_at, ) - .where(Photo.processing_status == 'failed') + .where(owner, Photo.processing_status == 'failed') .order_by(Photo.updated_at.desc().nullslast()) .limit(20) ) @@ -454,7 +471,11 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)): @router.get("/maintenance/pipeline-stats") -async def get_pipeline_stats(db: AsyncSession = Depends(get_db)): +async def get_pipeline_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + scope: str | None = Query(None), +): """Per-stage progress across the ingestion pipeline. Returns a `{stage_key: {done, total, label}}` map so the Settings @@ -470,7 +491,8 @@ async def get_pipeline_stats(db: AsyncSession = Depends(get_db)): from app.models import Embedding, FaceEmbedding, OCRText from app.models.tags import photo_tags # association Table, not a model - not_discarded = Photo.is_discarded.is_(False) + owner = _owner_filter(current_user, scope) + not_discarded = owner & Photo.is_discarded.is_(False) async def scalar_count(query): return (await db.execute(query)).scalar() or 0 @@ -654,7 +676,7 @@ async def get_pipeline_stats(db: AsyncSession = Depends(get_db)): @router.get("/maintenance/missing-stats") -async def get_missing_stats(): +async def get_missing_stats(current_user: User = Depends(get_current_user)): """Count photos whose files no longer exist on disk under a mounted source root. Surfaced in Settings so the user can see a number before pulling the trigger on prune-missing. Cheap enough to call freely.""" @@ -663,7 +685,7 @@ async def get_missing_stats(): @router.post("/maintenance/prune-missing") -async def run_prune_missing(): +async def run_prune_missing(current_user: User = Depends(get_current_user)): """Actually delete the orphaned photo rows reported by /missing-stats. Common cause: PHOTO_DIRS in .env was repointed at a different library leaving every old row dangling. Skips any photo whose source root @@ -677,7 +699,7 @@ async def run_prune_missing(): @router.post("/maintenance/cleanup") -async def run_data_integrity_cleanup(): +async def run_data_integrity_cleanup(current_user: User = Depends(get_current_user)): """Re-run the source-roots / folders / photos data-integrity cleanup that normally only runs on backend startup. Idempotent.""" from app.services.cleanup import cleanup_data_integrity @@ -695,7 +717,11 @@ async def run_data_integrity_cleanup(): # ───────────────────────────────────────────────────────────────────────── @router.get("/duplicates/groups") -async def get_duplicate_groups(db: AsyncSession = Depends(get_db)): +async def get_duplicate_groups( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + scope: str | None = Query(None), +): """Return every duplicate group with its members. Drives the frontend grouped grid view in the Duplicates section. One @@ -708,6 +734,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)): duplicates that the perceptual hash trivially caught) * "similar" — members differ at the byte level but match perceptually """ + owner = _owner_filter(current_user, scope) rows = ( await db.execute( select( @@ -723,6 +750,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)): Photo.media_type, Photo.duplicate_group_id, ) + .where(owner) .where(Photo.duplicate_group_id.is_not(None)) .where(Photo.is_discarded.is_(False)) .where(Photo.is_hidden.is_(False)) @@ -784,7 +812,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)): @router.post("/maintenance/regroup-duplicates") -async def trigger_regroup_duplicates(): +async def trigger_regroup_duplicates(current_user: User = Depends(get_current_user)): """Recompute duplicate groups from current perceptual hashes. Fires the celery `regroup_duplicates` task which walks every photo's @@ -800,7 +828,7 @@ async def trigger_regroup_duplicates(): @router.post("/maintenance/backfill-phashes") -async def trigger_backfill_phashes(): +async def trigger_backfill_phashes(current_user: User = Depends(get_current_user)): """Compute perceptual hashes for every photo currently missing one. One-shot recovery path for libraries that existed before the phash diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 2a63256..282538d 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -7,9 +7,10 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi.responses import FileResponse from pydantic import BaseModel -from sqlalchemy import select, and_, or_, func +from sqlalchemy import select, and_, or_, func, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +import base64 import json import os import logging @@ -19,11 +20,13 @@ logger = logging.getLogger(__name__) from app.database import get_db from app.models import Photo, Folder, Tag from app.models.folders import SourceRoot +from app.models.user import User from app.models.heaps import heap_photos from app.models.tags import photo_tags from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction from app.services.exif_writer import ExifWriteError, write_taken_at from app.services.date_guess import has_date_warning as compute_date_warning +from app.dependencies import get_current_user, get_user_photo from app.config import settings router = APIRouter() @@ -47,13 +50,15 @@ async def list_photos( order: str = "desc", page: int = Query(1, ge=1), per_page: int = Query(100, ge=1, le=500), - db: AsyncSession = Depends(get_db) + cursor: Optional[str] = Query(None, description="Opaque cursor for keyset pagination"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List photos with filters and pagination""" - + # Build query — eager-load tags so the response can include them - # without an N+1 round-trip per photo. - query = select(Photo).options(selectinload(Photo.tags)) + # without an N+1 round-trip per photo. Scoped to the current user. + query = select(Photo).options(selectinload(Photo.tags)).where(Photo.user_id == current_user.id) # Apply filters filters = [] @@ -206,24 +211,92 @@ async def list_photos( "rating": Photo.rating, } sort_column = SORT_WHITELIST.get(sort, Photo.taken_at) - if order == "desc": - query = query.order_by(sort_column.desc()) + desc = order == "desc" + + # Keyset / cursor pagination — O(1) regardless of page depth. + # The cursor encodes the last-seen (sort_value, id) pair so the DB + # can seek directly to the next slice via an indexed range scan + # instead of skipping N rows with OFFSET. + if cursor: + try: + decoded = json.loads(base64.urlsafe_b64decode(cursor)) + cursor_val = decoded["v"] + cursor_id = decoded["id"] + # For datetime columns, parse the ISO string back. + if sort in ("taken_at", "added_at") and cursor_val is not None: + cursor_val = datetime.fromisoformat(cursor_val) + except Exception: + raise HTTPException(status_code=400, detail="Invalid cursor") + + # Keyset condition: for DESC we want rows "less than" the cursor, + # for ASC rows "greater than". We use (sort_col, id) tuple + # comparison which handles NULLs and ties correctly. + if desc: + if cursor_val is None: + # NULL sorts last in DESC with NULLS LAST — seek past it by id + query = query.where( + or_( + sort_column.is_(None) & (Photo.id < cursor_id), + ) + ) + else: + query = query.where( + or_( + sort_column < cursor_val, + and_(sort_column == cursor_val, Photo.id < cursor_id), + sort_column.is_(None), + ) + ) + else: + if cursor_val is None: + query = query.where( + or_( + sort_column.is_(None) & (Photo.id > cursor_id), + ) + ) + else: + query = query.where( + or_( + sort_column > cursor_val, + and_(sort_column == cursor_val, Photo.id > cursor_id), + ) + ) + + if desc: + query = query.order_by(sort_column.desc().nulls_last(), Photo.id.desc()) else: - query = query.order_by(sort_column.asc()) - - # Count total results - count_query = select(func.count()).select_from(query.subquery()) - total_result = await db.execute(count_query) - total = total_result.scalar() - - # Apply pagination - offset = (page - 1) * per_page - query = query.offset(offset).limit(per_page) - + query = query.order_by(sort_column.asc().nulls_last(), Photo.id.asc()) + + # Count total results (only when no cursor — first page needs it; + # subsequent pages reuse the total from the first response). + total = None + if not cursor: + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() + + # Fallback to offset pagination when no cursor is provided and page > 1 + # (backward compat for any callers not yet using cursors). + if not cursor and page > 1: + offset = (page - 1) * per_page + query = query.offset(offset) + + query = query.limit(per_page) + # Execute query result = await db.execute(query) photos = result.scalars().all() - + + # Build next_cursor from the last row in this batch. + next_cursor = None + if photos and len(photos) == per_page: + last = photos[-1] + sort_val = getattr(last, sort if sort in SORT_WHITELIST else "taken_at") + if isinstance(sort_val, datetime): + sort_val = sort_val.isoformat() + cursor_payload = json.dumps({"v": sort_val, "id": last.id}) + next_cursor = base64.urlsafe_b64encode(cursor_payload.encode()).decode() + # Convert to response, attaching tags inline so the frontend can group # client-side without a second round-trip. photo_dicts = [] @@ -235,21 +308,25 @@ async def list_photos( ] photo_dicts.append(d) - return { + response = { "photos": photo_dicts, - "total": total, - "page": page, "per_page": per_page, - "pages": (total + per_page - 1) // per_page if total else 0, + "next_cursor": next_cursor, } + # Include total + legacy page fields on first page / non-cursor requests + if total is not None: + response["total"] = total + response["page"] = page + response["pages"] = (total + per_page - 1) // per_page if total else 0 + return response @router.get("/map") -async def list_photos_with_gps(db: AsyncSession = Depends(get_db)): +async def list_photos_with_gps( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): """Lightweight listing of every non-discarded photo that has GPS - coordinates, used by the Map view. Intentionally returns a flat list - (no pagination) with only the fields the map renderer needs, so even - large libraries serialize to a few MB at most. Declared *before* - ``/{photo_id}`` so the literal path wins the FastAPI route match.""" + coordinates, used by the Map view.""" result = await db.execute( select( Photo.id, @@ -257,6 +334,7 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)): Photo.longitude, Photo.taken_at, ).where( + Photo.user_id == current_user.id, Photo.is_discarded.is_(False), Photo.is_hidden.is_(False), Photo.latitude.is_not(None), @@ -277,16 +355,11 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)): @router.get("/{photo_id}") async def get_photo( photo_id: str, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Get single photo with full EXIF and its tags.""" - result = await db.execute( - select(Photo).where(Photo.id == photo_id) - ) - photo = result.scalar_one_or_none() - - if not photo: - raise HTTPException(status_code=404, detail="Photo not found") + photo = await get_user_photo(photo_id, current_user, db) # Fetch tags via the join table so we don't need to declare a # relationship on the Photo model side. @@ -310,12 +383,11 @@ async def add_photo_tags( photo_id: str, body: dict, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Add one or more tags to a photo. Body: { tag_ids: [str, ...] }. Idempotent: re-adding existing members is a no-op.""" - photo_result = await db.execute(select(Photo).where(Photo.id == photo_id)) - if photo_result.scalar_one_or_none() is None: - raise HTTPException(status_code=404, detail="Photo not found") + await get_user_photo(photo_id, current_user, db) tag_ids = body.get("tag_ids") or [] if not isinstance(tag_ids, list) or not tag_ids: @@ -346,8 +418,10 @@ async def remove_photo_tag( photo_id: str, tag_id: str, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Remove a tag from a photo. Removing a non-member is a no-op.""" + await get_user_photo(photo_id, current_user, db) from sqlalchemy import delete as sql_delete await db.execute( sql_delete(photo_tags).where( @@ -363,22 +437,21 @@ async def get_thumbnail( photo_id: str, size: str, response: Response, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Serve thumbnail (with Nginx X-Accel-Redirect support)""" if size not in ['small', 'medium', 'large']: raise HTTPException(status_code=400, detail="Invalid thumbnail size") - - result = await db.execute( - select(Photo).where(Photo.id == photo_id) - ) - photo = result.scalar_one_or_none() - - if not photo: - raise HTTPException(status_code=404, detail="Photo not found") - - # Check if thumbnail exists, generate if not - thumb_dir = f"/data/thumbs/{photo_id}" + + photo = await get_user_photo(photo_id, current_user, db) + + # Check if thumbnail exists, generate if not. + # User-prefixed path for isolation. + if photo.user_id: + thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}" + else: + thumb_dir = f"/data/thumbs/{photo_id}" thumb_path = f"{thumb_dir}/{size}.webp" if not os.path.exists(thumb_path): @@ -451,16 +524,11 @@ async def get_thumbnail( @router.get("/{photo_id}/original") async def get_original( photo_id: str, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Serve original file (download for RAW, inline for web-safe formats)""" - result = await db.execute( - select(Photo).where(Photo.id == photo_id) - ) - photo = result.scalar_one_or_none() - - if not photo: - raise HTTPException(status_code=404, detail="Photo not found") + photo = await get_user_photo(photo_id, current_user, db) if not os.path.exists(photo.filepath): raise HTTPException(status_code=404, detail="File not found") @@ -560,19 +628,10 @@ async def get_proxy( photo_id: str, response: Response, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): - """Serve a full-resolution WebP proxy for non-web-safe formats (RAW, HEIC, - TIFF) so the loupe view can display them inline. Web-safe formats are - redirected to /original to avoid pointless transcoding. - - Cached at /data/proxies/{photo_id}.webp; subsequent requests serve the - cached file (with optional X-Accel-Redirect for production). - """ - result = await db.execute(select(Photo).where(Photo.id == photo_id)) - photo = result.scalar_one_or_none() - - if not photo: - raise HTTPException(status_code=404, detail="Photo not found") + """Serve a full-resolution WebP proxy for non-web-safe formats.""" + photo = await get_user_photo(photo_id, current_user, db) if not os.path.exists(photo.filepath): raise HTTPException(status_code=404, detail="File not found") @@ -613,19 +672,11 @@ async def get_proxy( async def update_photo( photo_id: str, update: PhotoUpdate, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): - """Update photo metadata. If `filename` is included, also rename the - file on disk in its current directory (no cross-folder moves through - this endpoint). - """ - result = await db.execute( - select(Photo).where(Photo.id == photo_id) - ) - photo = result.scalar_one_or_none() - - if not photo: - raise HTTPException(status_code=404, detail="Photo not found") + """Update photo metadata.""" + photo = await get_user_photo(photo_id, current_user, db) update_data = update.dict(exclude_unset=True) @@ -692,19 +743,11 @@ async def update_photo( @router.delete("/{photo_id}") async def discard_photo( photo_id: str, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): - """Soft-discard a photo: sets is_discarded=true. The file stays on disk so - restore is just a flag flip. Permanent deletion happens via DELETE - /discard/{id} or DELETE /discard/empty. - """ - result = await db.execute( - select(Photo).where(Photo.id == photo_id) - ) - photo = result.scalar_one_or_none() - - if not photo: - raise HTTPException(status_code=404, detail="Photo not found") + """Soft-discard a photo.""" + photo = await get_user_photo(photo_id, current_user, db) photo.is_discarded = True photo.discarded_at = datetime.utcnow() @@ -726,6 +769,7 @@ class CopyRequest(BaseModel): async def copy_photos( body: CopyRequest, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Copy photos into a target folder. Same target resolution as /move (folder id or source root id), but uses shutil.copy2 and creates new @@ -767,7 +811,7 @@ async def copy_photos( return {"status": "success", "copied": 0, "errors": []} photos_result = await db.execute( - select(Photo).where(Photo.id.in_(body.photo_ids)) + select(Photo).where(Photo.id.in_(body.photo_ids), Photo.user_id == current_user.id) ) photos_to_copy = photos_result.scalars().all() @@ -820,6 +864,7 @@ async def copy_photos( file_size=photo.file_size, taken_at=photo.taken_at, taken_at_source=photo.taken_at_source, + user_id=current_user.id, user_title=photo.user_title, user_notes=photo.user_notes, rating=photo.rating, @@ -844,6 +889,7 @@ async def copy_photos( async def move_photos( body: MoveRequest, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Move photos into a target folder. The target can be either a Folder id or a SourceRoot id (since the LeftSidebar only exposes source roots @@ -887,9 +933,9 @@ async def move_photos( if not body.photo_ids: return {"status": "success", "moved": 0, "errors": []} - # Fetch the photo rows + # Fetch the photo rows, scoped to user photos_result = await db.execute( - select(Photo).where(Photo.id.in_(body.photo_ids)) + select(Photo).where(Photo.id.in_(body.photo_ids), Photo.user_id == current_user.id) ) photos_to_move = photos_result.scalars().all() @@ -932,12 +978,13 @@ async def move_photos( @router.post("/bulk") async def bulk_action( action: BulkAction, - db: AsyncSession = Depends(get_db) + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Perform bulk actions on multiple photos""" - # Get photos + # Get photos, scoped to user result = await db.execute( - select(Photo).where(Photo.id.in_(action.ids)) + select(Photo).where(Photo.id.in_(action.ids), Photo.user_id == current_user.id) ) photos = result.scalars().all() diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py index 3954fed..9d28d49 100644 --- a/backend/app/routers/search.py +++ b/backend/app/routers/search.py @@ -10,6 +10,8 @@ from sqlalchemy import select from app.database import get_db from app.models import Photo from app.services.search import hybrid_search +from app.models.user import User +from app.dependencies import get_current_user router = APIRouter() @@ -22,7 +24,7 @@ class SearchRequest(BaseModel): @router.post("") -async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)): +async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Unified search endpoint. Every query runs hybrid (FTS + semantic) by default — the user never picks a mode. @@ -47,7 +49,7 @@ async def search_photos(body: SearchRequest, db: AsyncSession = Depends(get_db)) # Hydrate with photo data photo_ids = [r["photo_id"] for r in results] - stmt = select(Photo).where(Photo.id.in_(photo_ids)) + stmt = select(Photo).where(Photo.id.in_(photo_ids), Photo.user_id == current_user.id) rows = (await db.execute(stmt)).scalars().all() photo_map = {p.id: p for p in rows} diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py index 8ae8ba0..d10a90b 100644 --- a/backend/app/routers/tags.py +++ b/backend/app/routers/tags.py @@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import Photo, Tag from app.models.tags import photo_tags +from app.models.user import User +from app.dependencies import get_current_user router = APIRouter() @@ -42,6 +44,7 @@ class TagMerge(BaseModel): async def list_tags( kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"), db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """List all tags with their photo counts, optionally filtered by kind. @@ -61,6 +64,7 @@ async def list_tags( photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id) ) .where( + Photo.user_id == current_user.id, Photo.is_discarded.is_(False), Photo.is_hidden.is_(False), ) @@ -70,6 +74,7 @@ async def list_tags( stmt = ( select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id) .outerjoin(count_subq, Tag.id == count_subq.c.tag_id) + .where(Tag.user_id == current_user.id) ) if kind: stmt = stmt.where(Tag.kind == kind) @@ -93,7 +98,7 @@ async def list_tags( @router.post("", status_code=201) -async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)): +async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Create a new tag. The (name, kind) pair is unique — re-creating an existing pair returns the existing row (idempotent for autocomplete).""" name = (body.name or "").strip() @@ -101,7 +106,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)): raise HTTPException(status_code=400, detail="Tag name is required") existing = await db.execute( - select(Tag).where(Tag.name == name, Tag.kind == body.kind) + select(Tag).where(Tag.name == name, Tag.kind == body.kind, Tag.user_id == current_user.id) ) found = existing.scalar_one_or_none() if found: @@ -110,7 +115,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)): "kind": found.kind, "photo_count": 0, } - tag = Tag(name=name, color=body.color, kind=body.kind) + tag = Tag(name=name, color=body.color, kind=body.kind, user_id=current_user.id) db.add(tag) await db.commit() await db.refresh(tag) @@ -122,10 +127,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)): @router.patch("/{tag_id}") async def update_tag( - tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db) + tag_id: str, body: TagUpdate, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Rename or recolor a tag (works for any kind — user, object, face_cluster).""" - result = await db.execute(select(Tag).where(Tag.id == tag_id)) + result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id)) tag = result.scalar_one_or_none() if not tag: raise HTTPException(status_code=404, detail="Tag not found") @@ -145,7 +151,8 @@ async def update_tag( @router.post("/{tag_id}/merge") async def merge_tag( - tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db) + tag_id: str, body: TagMerge, db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), ): """Merge tag_id INTO target_id. All photo associations from the source tag are moved to the target, then the source tag is deleted. @@ -155,8 +162,8 @@ async def merge_tag( if tag_id == body.target_id: raise HTTPException(status_code=400, detail="Cannot merge a tag into itself") - source = (await db.execute(select(Tag).where(Tag.id == tag_id))).scalar_one_or_none() - target = (await db.execute(select(Tag).where(Tag.id == body.target_id))).scalar_one_or_none() + source = (await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id))).scalar_one_or_none() + target = (await db.execute(select(Tag).where(Tag.id == body.target_id, Tag.user_id == current_user.id))).scalar_one_or_none() if not source: raise HTTPException(status_code=404, detail="Source tag not found") if not target: @@ -189,9 +196,9 @@ async def merge_tag( @router.delete("/{tag_id}", status_code=204) -async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db)): +async def delete_tag(tag_id: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Delete a tag. Photo associations cascade-delete via the FK.""" - result = await db.execute(select(Tag).where(Tag.id == tag_id)) + result = await db.execute(select(Tag).where(Tag.id == tag_id, Tag.user_id == current_user.id)) tag = result.scalar_one_or_none() if not tag: raise HTTPException(status_code=404, detail="Tag not found") diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 3aa9b07..6c3ccab 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -1,6 +1,5 @@ """ -Scanner service for initial library scan and one-time bootstrap of the -default source root on first boot. +Scanner service for initial library scan and per-user source root bootstrap. """ import os import logging @@ -8,44 +7,74 @@ from sqlalchemy import select from app.database import AsyncSessionLocal from app.models import SourceRoot +from app.models.user import User from app.tasks.scan import scan_all_source_roots from app.config import settings logger = logging.getLogger(__name__) -# The single host → container mount path. The compose file mounts whatever -# the user set as PHOTO_DIRS at this path. -DEFAULT_LIBRARY_PATH = "/photos" -DEFAULT_LIBRARY_NAME = "Library" + +async def bootstrap_user_source_root(user: User, session=None) -> None: + """Create the media directory and a source root for a user. + + Called when a new user is created (by the admin or the setup endpoint). + If the user already has a source root, this is a no-op. + """ + own_session = session is None + if own_session: + session = AsyncSessionLocal() + + try: + # Check if user already has a source root + result = await session.execute( + select(SourceRoot).where(SourceRoot.user_id == user.id) + ) + if result.scalar_one_or_none() is not None: + return + + os.makedirs(user.media_path, exist_ok=True) + + source_root = SourceRoot( + name=f"{user.username}'s Library", + path=user.media_path, + user_id=user.id, + ) + session.add(source_root) + if own_session: + await session.commit() + else: + await session.flush() + + logger.info( + f"Bootstrapped source root for user '{user.username}': " + f"{user.media_path}" + ) + finally: + if own_session: + await session.close() async def bootstrap_default_source_root() -> None: - """If no source roots exist in the DB, create one pointing at the default - library mount. Lets a fresh install pick up photos with zero - configuration: the user only needs to set PHOTO_DIRS in .env. + """Legacy bootstrap — for existing installs that have source roots + without user_id (pre-auth migration). On fresh installs, source roots + are created per-user via bootstrap_user_source_root. If there are + already source roots in the DB, this is a no-op. """ - if not os.path.isdir(DEFAULT_LIBRARY_PATH): - logger.warning( - f"Default library path {DEFAULT_LIBRARY_PATH} is not mounted; " - "set PHOTO_DIRS in .env and recreate the container." - ) - return - async with AsyncSessionLocal() as session: result = await session.execute(select(SourceRoot)) if result.scalars().first() is not None: - return # Already have at least one source root, leave it alone. + return # Already have source roots. - source_root = SourceRoot( - name=DEFAULT_LIBRARY_NAME, - path=DEFAULT_LIBRARY_PATH, - ) - session.add(source_root) - await session.commit() - logger.info( - f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME} → " - f"{DEFAULT_LIBRARY_PATH}" - ) + # No source roots and no users means fresh install — the setup + # endpoint will create the first user + source root. + user_count = (await session.execute( + select(User) + )).scalars().first() + if user_count is None: + logger.info( + "No users or source roots — waiting for first-run setup." + ) + return async def start_initial_scan(): diff --git a/backend/app/tasks/scan.py b/backend/app/tasks/scan.py index 089cfd8..8f317aa 100644 --- a/backend/app/tasks/scan.py +++ b/backend/app/tasks/scan.py @@ -122,6 +122,13 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta if not source_root_id: source_root = await get_or_create_source_root(session, folder_path) source_root_id = source_root.id + else: + source_root = (await session.execute( + select(SourceRoot).where(SourceRoot.id == source_root_id) + )).scalar_one_or_none() + + # Inherit user_id from the source root's owner + owner_user_id = source_root.user_id if source_root else None # Per-scan memoization cache for "is this folder's effective # is_hidden true?" Populated on first lookup by walking the @@ -173,7 +180,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta for root, dirs, files in os.walk(folder_path): # Get or create folder entry - folder = await get_or_create_folder(session, root, source_root_id) + folder = await get_or_create_folder(session, root, source_root_id, owner_user_id) progress_set(REDIS_KEY_CURRENT_FOLDER, root) # Filter supported files @@ -237,6 +244,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta filepath=filepath, filename=filename, folder_id=folder.id, + user_id=owner_user_id, file_hash=file_hash, media_type=get_media_type(filepath), original_format=Path(filepath).suffix.upper()[1:], @@ -342,7 +350,9 @@ async def get_or_create_source_root(session: AsyncSession, path: str) -> SourceR return source_root -async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: str) -> Folder: +async def get_or_create_folder( + session: AsyncSession, path: str, source_root_id: str, user_id: str = None +) -> Folder: """Get or create a folder entry, matching by normalized path.""" from sqlalchemy import select @@ -364,7 +374,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: parent_id = parent.id else: # Recursively create parent - parent = await get_or_create_folder(session, parent_path, source_root_id) + parent = await get_or_create_folder(session, parent_path, source_root_id, user_id) parent_id = parent.id else: parent_id = None @@ -374,6 +384,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id: path=norm, parent_id=parent_id, source_root_id=source_root_id, + user_id=user_id, ) session.add(folder) await session.flush() diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 0a8bdb8..5364aa6 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -47,9 +47,16 @@ THUMB_SIZES = { 'large': settings.thumbnails.large } -def get_thumb_path(photo_id: str, size: str) -> str: - """Get the path for a thumbnail file""" - thumb_dir = f"/data/thumbs/{photo_id}" +def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str: + """Get the path for a thumbnail file. + + When user_id is provided, thumbnails are stored under a user-specific + subdirectory to enforce isolation between users. + """ + if user_id: + thumb_dir = f"/data/thumbs/{user_id}/{photo_id}" + else: + thumb_dir = f"/data/thumbs/{photo_id}" os.makedirs(thumb_dir, exist_ok=True) return f"{thumb_dir}/{size}.{settings.thumbnails.format}" @@ -302,7 +309,7 @@ async def _generate_thumbnails_async(photo_id: str, task): # Generate thumbnails for each size for size_name, size_value in THUMB_SIZES.items(): - thumb_path = get_thumb_path(photo_id, size_name) + thumb_path = get_thumb_path(photo_id, size_name, photo.user_id) generate_thumbnail(image, size_value, thumb_path) # Update database with thumbnail path diff --git a/backend/bootstrap.py b/backend/bootstrap.py new file mode 100644 index 0000000..4a012ed --- /dev/null +++ b/backend/bootstrap.py @@ -0,0 +1,54 @@ +"""Post-init_db bootstrap: run or stamp Alembic migrations. + +On a FRESH Postgres install, init_db's create_all has already built the +full schema from the current models. Running `alembic upgrade head` would +fail because the older migrations try ADD COLUMN on columns that already +exist. So we detect the fresh-install case (alembic_version table is +missing or empty) and `stamp head` instead. + +On an EXISTING install, the alembic_version table has a revision and +`upgrade head` applies only the new deltas. +""" +import subprocess +import sys + +from sqlalchemy import create_engine, text, inspect +from app.config import settings + + +def run(): + # Use a sync engine for this one-shot script. + sync_url = settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", "") + engine = create_engine(sync_url) + + with engine.connect() as conn: + inspector = inspect(engine) + tables = inspector.get_table_names() + + if "alembic_version" not in tables: + # Fresh install — create_all built everything. Stamp head. + print("Fresh install detected — stamping alembic head") + subprocess.run( + [sys.executable, "-m", "alembic", "stamp", "head"], + check=True, + ) + else: + row = conn.execute(text("SELECT version_num FROM alembic_version")).first() + if row is None: + print("Empty alembic_version — stamping head") + subprocess.run( + [sys.executable, "-m", "alembic", "stamp", "head"], + check=True, + ) + else: + print(f"Existing install at revision {row[0]} — running alembic upgrade head") + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + check=True, + ) + + engine.dispose() + + +if __name__ == "__main__": + run() diff --git a/backend/requirements.txt b/backend/requirements.txt index 3c88c42..66652ce 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -54,6 +54,7 @@ aiofiles==23.2.1 # Security and authentication python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 # Development pytest==7.4.4 diff --git a/docker-compose.sqlite.yml b/docker-compose.sqlite.yml index 37adffb..dabc67f 100644 --- a/docker-compose.sqlite.yml +++ b/docker-compose.sqlite.yml @@ -24,6 +24,9 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379 - PHOTO_DIRS=${PHOTO_DIRS:-/photos} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} + - SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me} + - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + - REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30} - LOG_LEVEL=${LOG_LEVEL:-INFO} - TZ=${TZ:-UTC} depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index 106b832..af347a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,11 @@ services: # Run Alembic migrations before starting uvicorn. On a fresh Postgres # the empty 0001 baseline is a no-op stamp; create_all in init_db then # builds the schema. - command: sh -c "python -c \"import asyncio; from app.database import init_db; asyncio.run(init_db())\" && alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" + # init_db creates all tables from models (idempotent create_all), + # then Alembic runs migrations for existing installs. On fresh DBs + # create_all already built the full schema, so bootstrap.py stamps + # alembic head to skip redundant ALTER statements. + command: sh -c "python -c 'import asyncio; from app.database import init_db; asyncio.run(init_db())' && python bootstrap.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" environment: - DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita - REDIS_URL=redis://redis:6379 @@ -46,6 +50,9 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379 - PHOTO_DIRS=${PHOTO_DIRS:-/photos} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} + - SECRET_KEY=${SECRET_KEY:-mulita-dev-secret-change-me} + - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + - REFRESH_TOKEN_EXPIRE_DAYS=${REFRESH_TOKEN_EXPIRE_DAYS:-30} - LOG_LEVEL=${LOG_LEVEL:-INFO} - TZ=${TZ:-UTC} depends_on: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 887d828..58bb4cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,17 +15,19 @@ import { KeyboardHints } from './components/KeyboardHints' import { PreviewView } from './components/preview/PreviewView' import { FilterBar } from './components/filter/FilterBar' import { DiscardActionBar } from './components/discard/DiscardActionBar' -import { SettingsDialog } from './components/dialogs/SettingsDialog' +import { SettingsPage } from './components/dialogs/SettingsDialog' import { usePhotoStore } from './store/photoStore' import { useFilterStore } from './store/filterStore' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' import { useFilterUrlSync } from './hooks/useFilterUrlSync' import { usePhotosQuery } from './hooks/usePhotosQuery' +import { AuthProvider, useAuth } from './contexts/AuthContext' +import { LoginPage } from './components/auth/LoginPage' +import { SetupPage } from './components/auth/SetupPage' -function App() { +function MainApp() { const [leftSidebarOpen, setLeftSidebarOpen] = useState(true) const [rightSidebarOpen, setRightSidebarOpen] = useState(true) - const [settingsOpen, setSettingsOpen] = useState(false) const viewMode = usePhotoStore((state) => state.viewMode) const currentSection = useFilterStore((s) => s.currentSection) @@ -44,13 +46,13 @@ function App() { getFirstPhotoId: () => allPhotos?.[0]?.id ?? null, }) + // Settings page is a full-page section — hide filter bar, right sidebar, + // and keyboard hints when it's active. + const isSettings = currentSection === 'settings' + // Right sidebar stays open by default and shows whatever's selected // (or an empty state if nothing is). User can still toggle it manually. - // Note: deliberately NOT gated on viewMode — the preview overlay sits - // on top with z-[1000], so leaving the sidebar mounted underneath - // costs nothing visually and avoids the collapse-then-reopen layout - // shift the user would otherwise see every time they exit preview. - const showRightSidebar = rightSidebarOpen + const showRightSidebar = rightSidebarOpen && !isSettings return (
@@ -70,7 +72,6 @@ function App() { > setLeftSidebarOpen(false)} - onOpenSettings={() => setSettingsOpen(true)} />
@@ -79,14 +80,12 @@ function App() { * across the sidebar. relative so the KeyboardHints overlay * centers against this column, not the viewport. */}
- - + {!isSettings && } + {!isSettings && }
- {/* Section-level routing. The Map view replaces the timeline - * with a Leaflet map of GPS-tagged photos; Duplicates gets its - * own grouped grid; everything else falls through to the - * filter-driven Timeline. */} - {currentSection === 'map' ? ( + {currentSection === 'settings' ? ( + + ) : currentSection === 'map' ? ( ) : currentSection === 'duplicates' ? ( @@ -102,10 +101,7 @@ function App() { )}
- {/* Floating keyboard hints — bottom-center of the main column, - * glassy. Mounted here so it's centered against the timeline, - * not the viewport (which would be offset by the sidebars). */} - + {!isSettings && }
{/* Right Sidebar */} @@ -127,13 +123,33 @@ function App() { {/* Preview overlay — covers TopBar when active */} {viewMode === 'preview' && } - {/* Settings panel — admin/maintenance actions */} - setSettingsOpen(false)} - /> ) } +/** Auth-gated shell: shows setup, login, or the main app. */ +function App() { + return ( + + + + ) +} + +function AuthGate() { + const { user, isLoading, needsSetup } = useAuth() + + if (isLoading) { + return ( +
+
Loading…
+
+ ) + } + + if (needsSetup) return + if (!user) return + return +} + export default App \ No newline at end of file diff --git a/frontend/src/components/admin/UserManagement.tsx b/frontend/src/components/admin/UserManagement.tsx new file mode 100644 index 0000000..57ab1d3 --- /dev/null +++ b/frontend/src/components/admin/UserManagement.tsx @@ -0,0 +1,336 @@ +import { useState, useEffect, useCallback } from 'react' +import { Plus, Pencil, UserX, Shield, User as UserIcon } from 'lucide-react' +import { admin, type AdminUser } from '../../services/api' + +export function UserManagement() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [error, setError] = useState(null) + + const fetchUsers = useCallback(async () => { + try { + const data = await admin.listUsers() + setUsers(data.users) + } catch { + setError('Failed to load users.') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchUsers() + }, [fetchUsers]) + + if (loading) { + return
Loading users…
+ } + + return ( +
+
+

Users

+ +
+ + {error && ( +
+ {error} +
+ )} + + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
UsernameRolePhotosStatusActions
+
+ {u.role === 'admin' ? ( + + ) : ( + + )} + {u.username} +
+
{u.role} + {u.photo_count.toLocaleString()} + + + {u.is_active ? 'Active' : 'Inactive'} + + +
+ + {u.is_active && ( + + )} +
+
+ + {showCreate && ( + setShowCreate(false)} + onCreated={() => { + setShowCreate(false) + fetchUsers() + }} + /> + )} + + {editingUser && ( + setEditingUser(null)} + onSaved={() => { + setEditingUser(null) + fetchUsers() + }} + /> + )} +
+ ) +} + +// ── Create User Modal ────────────────────────────────────────────────── + +function CreateUserModal({ + onClose, + onCreated, +}: { + onClose: () => void + onCreated: () => void +}) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [role, setRole] = useState<'user' | 'admin'>('user') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async () => { + setError(null) + setLoading(true) + try { + await admin.createUser({ username: username.trim(), password, role }) + onCreated() + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Failed to create user.') + } finally { + setLoading(false) + } + } + + return ( + + {error && ( +
+ {error} +
+ )} +
+ + setUsername(e.target.value)} + className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" + autoFocus + /> + + + setPassword(e.target.value)} + className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" + /> + + + + +
+
+ + +
+
+ ) +} + +// ── Edit User Modal ──────────────────────────────────────────────────── + +function EditUserModal({ + user, + onClose, + onSaved, +}: { + user: AdminUser + onClose: () => void + onSaved: () => void +}) { + const [role, setRole] = useState(user.role) + const [newPassword, setNewPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async () => { + setError(null) + setLoading(true) + try { + const data: { role?: string; new_password?: string } = {} + if (role !== user.role) data.role = role + if (newPassword) data.new_password = newPassword + if (Object.keys(data).length > 0) { + await admin.updateUser(user.id, data) + } + onSaved() + } catch (err: any) { + setError(err.response?.data?.detail ?? 'Failed to update user.') + } finally { + setLoading(false) + } + } + + return ( + + {error && ( +
+ {error} +
+ )} +
+ + + + + setNewPassword(e.target.value)} + className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent" + placeholder="Unchanged" + /> + +
+
+ + +
+
+ ) +} + +// ── Shared helpers ───────────────────────────────────────────────────── + +function ModalOverlay({ + onClose: _onClose, + title, + children, +}: { + onClose: () => void + title: string + children: React.ReactNode +}) { + return ( +
+
e.stopPropagation()}> +

{title}

+ {children} +
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..ae8dbe2 --- /dev/null +++ b/frontend/src/components/auth/LoginPage.tsx @@ -0,0 +1,81 @@ +import { useState, type FormEvent } from 'react' +import { useAuth } from '../../contexts/AuthContext' + +export function LoginPage() { + const { login } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + try { + await login(username, password) + } catch (err: any) { + setError( + err.response?.data?.detail ?? 'Unable to sign in. Check your credentials.', + ) + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ Sign in to Mulita +

+ + {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + required + autoFocus + className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" + /> +
+ + +
+
+ ) +} diff --git a/frontend/src/components/auth/SetupPage.tsx b/frontend/src/components/auth/SetupPage.tsx new file mode 100644 index 0000000..a02ca79 --- /dev/null +++ b/frontend/src/components/auth/SetupPage.tsx @@ -0,0 +1,119 @@ +import { useState, type FormEvent } from 'react' +import { useAuth } from '../../contexts/AuthContext' +import api from '../../services/api' + +export function SetupPage() { + const { onSetupComplete } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(null) + + if (password !== confirmPassword) { + setError('Passwords do not match.') + return + } + if (password.length < 6) { + setError('Password must be at least 6 characters.') + return + } + if (username.trim().length < 2) { + setError('Username must be at least 2 characters.') + return + } + + setLoading(true) + try { + const res = await api.post('/auth/setup', { + username: username.trim(), + password, + }) + const { access_token, refresh_token } = res.data + await onSetupComplete(access_token, refresh_token) + } catch (err: any) { + setError( + err.response?.data?.detail ?? 'Setup failed. Please try again.', + ) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

Welcome to Mulita

+

+ Create your admin account to get started. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + required + autoFocus + className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full rounded border border-border bg-bg px-3 py-2 text-sm text-text outline-none focus:border-accent" + /> +
+ + +
+
+ ) +} diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index 684a991..0f2bd23 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -1,6 +1,5 @@ import { useEffect, useState, useCallback, useRef } from 'react' import { - X, RefreshCw, Wrench, Film, @@ -15,6 +14,7 @@ import { Sparkles, Activity, FolderSearch, + Shield, } from 'lucide-react' import clsx from 'clsx' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -26,6 +26,8 @@ import { type WorkerStatus, } from '../../services/api' import { toast } from '../ToastContainer' +import { useAuth } from '../../contexts/AuthContext' +import { UserManagement } from '../admin/UserManagement' // React Query keys for the settings panels. Kept here (not in a shared // hook module) since they're internal to this dialog and used by the @@ -40,24 +42,22 @@ const SETTINGS_SCAN_STATUS_KEY = ['settings', 'scan-status'] as const // the grid renders from. Imported via the canonical hook key. import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery' -interface SettingsDialogProps { - isOpen: boolean - onClose: () => void -} +type SettingsTab = 'library' | 'users' + +const TABS: { id: SettingsTab; label: string; adminOnly?: boolean }[] = [ + { id: 'library', label: 'Library Management' }, + { id: 'users', label: 'Users', adminOnly: true }, +] /** - * Catch-all "settings + admin" panel. Currently exposes the maintenance - * endpoints exposed by /api/v1/library/maintenance/* — regenerate - * thumbnails (with filters), run the data-integrity cleanup, and trigger - * a full library re-scan. The thumbnail stats block is the entry point - * users will look at to understand what's going on after a scan. - * - * Each action is gated by an in-flight flag so double-clicks don't - * stack background jobs, and the stats block re-fetches whenever the - * dialog opens or after any action completes. + * Full-page settings view with tabbed navigation. Replaces the old + * modal dialog — renders as a top-level section in the main content + * area (like Timeline or MapView). */ -export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { +export function SettingsPage() { + const { isAdmin } = useAuth() const queryClient = useQueryClient() + const [activeTab, setActiveTab] = useState('library') const [showAllErrors, setShowAllErrors] = useState(false) // One key per action so each button has its own spinner without // blocking the others. @@ -65,67 +65,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { // All four panels fetch through React Query so cached data shows // instantly on reopen while a background refetch updates the numbers. - // `enabled: isOpen` avoids firing requests while the dialog is closed, - // but the cache entries survive between opens (default gcTime = 5m). + // All queries use scope=global so the admin sees cross-user totals. const thumbStatsQuery = useQuery({ queryKey: SETTINGS_THUMB_STATS_KEY, - queryFn: library.maintenance.thumbnailStats, - enabled: isOpen, - // Treat as stale immediately so reopening the dialog triggers a - // background refetch on top of the cached view. + queryFn: () => library.maintenance.thumbnailStats('global'), staleTime: 0, }) const libStatsQuery = useQuery({ queryKey: SETTINGS_LIB_STATS_KEY, - queryFn: library.stats, - enabled: isOpen, + queryFn: () => library.stats('global'), staleTime: 0, }) - // Worker status polls every 5s while the dialog is open — `refetchInterval` - // replaces the old setInterval loop. Missing-stats is relatively cheap - // but shares the same 5s rhythm to keep the orphan banner live. const workerStatusQuery = useQuery({ queryKey: SETTINGS_WORKER_STATUS_KEY, - queryFn: library.maintenance.workerStatus, - enabled: isOpen, - refetchInterval: isOpen ? 5000 : false, + queryFn: () => library.maintenance.workerStatus('global'), + refetchInterval: 5000, staleTime: 0, }) const missingStatsQuery = useQuery({ queryKey: SETTINGS_MISSING_STATS_KEY, queryFn: library.maintenance.missingStats, - enabled: isOpen, - refetchInterval: isOpen ? 5000 : false, + refetchInterval: 5000, staleTime: 0, }) - // Pipeline progress polls on the same 5s cadence as the worker status - // so both cards update together. Cheap query — ten COUNT(*)s on - // indexed columns. const pipelineStatsQuery = useQuery({ queryKey: SETTINGS_PIPELINE_STATS_KEY, - queryFn: library.maintenance.pipelineStats, - enabled: isOpen, - refetchInterval: isOpen ? 5000 : false, + queryFn: () => library.maintenance.pipelineStats('global'), + refetchInterval: 5000, staleTime: 0, }) - // Scan status — polls fast (2s) so the progress bar feels live during - // a scan, and slow (15s) when idle to cut chatter. `isScanning` is - // read from the latest fetched value so the cadence flips on its own - // the moment a scan kicks off or finishes. const scanStatusQuery = useQuery({ queryKey: SETTINGS_SCAN_STATUS_KEY, queryFn: library.scanStatus, - enabled: isOpen, refetchInterval: (q) => - isOpen ? ((q.state.data as ScanStatus | undefined)?.is_scanning ? 2000 : 15000) : false, + (q.state.data as ScanStatus | undefined)?.is_scanning ? 2000 : 15000, staleTime: 0, }) - // Duplicates: shares the cache with DuplicatesView so a regroup - // triggered from Settings updates the grid view immediately. const duplicatesQuery = useQuery({ - queryKey: DUPLICATE_GROUPS_QUERY_KEY, - queryFn: library.duplicates.groups, - enabled: isOpen, + queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, 'global'], + queryFn: () => library.duplicates.groups('global'), staleTime: 0, }) @@ -170,29 +148,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { // Surface fetch errors once (React Query de-dupes retries but we still // want a single toast so the user knows something went wrong). useEffect(() => { - if (!isOpen) return if (thumbStatsQuery.error || libStatsQuery.error) { console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error) toast.error('Could not load library stats') } - }, [isOpen, thumbStatsQuery.error, libStatsQuery.error]) + }, [thumbStatsQuery.error, libStatsQuery.error]) useEffect(() => { - if (!isOpen) return if (workerStatusQuery.error || missingStatsQuery.error) { console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error) toast.error('Could not load worker status') } - }, [isOpen, workerStatusQuery.error, missingStatsQuery.error]) - - // Esc closes. - useEffect(() => { - if (!isOpen) return - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, [isOpen, onClose]) + }, [workerStatusQuery.error, missingStatsQuery.error]) const runAction = useCallback( async ( @@ -229,39 +195,40 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { ) => runAction( key, - () => library.maintenance.regenerateThumbnails(body), + () => library.maintenance.regenerateThumbnails(body, 'global'), 'Regeneration queued', (r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared` ), [runAction] ) - if (!isOpen) return null + const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin) return ( -
-
-
-
- {/* Header */} -
-

Settings

- -
+
+ {/* Tab bar */} +
+ {visibleTabs.map((tab) => ( + + ))} +
-
- {/* ----------------------------------------------------- */} - {/* Library overview */} - {/* ----------------------------------------------------- */} + {/* Tab content */} +
+
+ + {activeTab === 'library' && (<>
} title="Library" @@ -334,9 +301,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
- {/* ----------------------------------------------------- */} - {/* Pipeline progress — per-stage done/total */} - {/* ----------------------------------------------------- */}
} title="Pipeline progress" @@ -379,9 +343,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { )}
- {/* ----------------------------------------------------- */} - {/* Duplicate detection */} - {/* ----------------------------------------------------- */}
} title="Duplicates" @@ -433,9 +394,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
- {/* ----------------------------------------------------- */} - {/* Thumbnail maintenance */} - {/* ----------------------------------------------------- */} +
} title="Thumbnails" @@ -524,9 +483,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
- {/* ----------------------------------------------------- */} - {/* Worker fleet diagnostics */} - {/* ----------------------------------------------------- */}
} title="Workers" @@ -848,9 +804,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { )}
- {/* ----------------------------------------------------- */} - {/* Data integrity */} - {/* ----------------------------------------------------- */}
} title="Maintenance" @@ -876,7 +829,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
-
+ )} + + {activeTab === 'users' && isAdmin && ( +
} + title="User Management" + > + +
+ )} +
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index dfc1b26..7b27dd3 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -20,6 +20,9 @@ import { Users, Eye, EyeOff, + User as UserIcon, + LogOut, + Shield, } from 'lucide-react' import clsx from 'clsx' import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api' @@ -40,6 +43,7 @@ import { import { registerUndoable } from '../../store/undoStore' import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' +import { useAuth } from '../../contexts/AuthContext' interface TreeItem { id: string @@ -55,10 +59,10 @@ interface TreeItem { interface LeftSidebarProps { onCollapse: () => void - onOpenSettings: () => void } -export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { +export function LeftSidebar({ onCollapse }: LeftSidebarProps) { + const { user, isAdmin, logout } = useAuth() const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) // Inline rename state for source-root rows. Stores the id being edited // and the draft name. Double-click a folder row to start. @@ -797,17 +801,40 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { - {/* Settings entry point — pinned to the bottom of the panel so it - * sits out of the way of the library tree but is always reachable. */} -
- + {/* Bottom panel — user identity + settings, pinned below the tree. */} +
+ {/* User row */} +
+ + {user?.username} + {isAdmin && ( + + + + )} + +
+ + {/* Settings — admin only, navigates to the settings section */} + {isAdmin && ( + + )}
-
+
+ + Built with hubris • {toRoman(new Date().getFullYear())} + {!rightSidebarOpen && ( )} - - Built with hubris • {toRoman(new Date().getFullYear())} -
) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..7828d12 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,170 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, + type ReactNode, +} from 'react' +import api from '../services/api' + +export interface AuthUser { + id: string + username: string + email: string | null + role: 'admin' | 'user' + is_active: boolean +} + +interface AuthContextValue { + user: AuthUser | null + isAdmin: boolean + isLoading: boolean + /** True when the backend has no users yet (first-run). */ + needsSetup: boolean + login: (username: string, password: string) => Promise + logout: () => void + /** Called after the setup endpoint creates the first admin. */ + onSetupComplete: (accessToken: string, refreshToken: string) => Promise +} + +const AuthContext = createContext(null) + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} + +// ── Token helpers ────────────────────────────────────────────────────── + +function getStoredToken(): string | null { + return localStorage.getItem('access_token') +} + +function storeToken(token: string) { + localStorage.setItem('access_token', token) +} + +function clearToken() { + localStorage.removeItem('access_token') +} + +// ── Provider ─────────────────────────────────────────────────────────── + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [needsSetup, setNeedsSetup] = useState(false) + // Keep refresh token in memory only (not localStorage). + const refreshTokenRef = useRef(null) + const refreshTimerRef = useRef | null>(null) + + const isAdmin = user?.role === 'admin' + + // Schedule a token refresh ~5 min before expiry. + const scheduleRefresh = useCallback((accessToken: string) => { + try { + const payload = JSON.parse(atob(accessToken.split('.')[1])) + const expiresAt = payload.exp * 1000 + const refreshIn = Math.max(expiresAt - Date.now() - 5 * 60 * 1000, 10_000) + + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) + refreshTimerRef.current = setTimeout(async () => { + if (!refreshTokenRef.current) return + try { + const res = await api.post('/auth/refresh', { + refresh_token: refreshTokenRef.current, + }) + const { access_token, refresh_token } = res.data + storeToken(access_token) + refreshTokenRef.current = refresh_token + scheduleRefresh(access_token) + } catch { + // Refresh failed — force re-login. + clearToken() + refreshTokenRef.current = null + setUser(null) + } + }, refreshIn) + } catch { + // Malformed token — ignore. + } + }, []) + + const fetchMe = useCallback(async () => { + try { + const res = await api.get('/auth/me') + setUser(res.data) + } catch { + clearToken() + setUser(null) + } + }, []) + + // Boot: check if setup is needed, then try to restore session. + useEffect(() => { + ;(async () => { + try { + const statusRes = await api.get('/auth/status') + if (!statusRes.data.setup_completed) { + setNeedsSetup(true) + setIsLoading(false) + return + } + } catch { + // Backend unreachable — fall through to login screen. + } + + const token = getStoredToken() + if (token) { + await fetchMe() + scheduleRefresh(token) + } + setIsLoading(false) + })() + + return () => { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) + } + }, [fetchMe, scheduleRefresh]) + + const login = useCallback( + async (username: string, password: string) => { + const res = await api.post('/auth/login', { username, password }) + const { access_token, refresh_token } = res.data + storeToken(access_token) + refreshTokenRef.current = refresh_token + scheduleRefresh(access_token) + await fetchMe() + }, + [fetchMe, scheduleRefresh], + ) + + const logout = useCallback(() => { + clearToken() + refreshTokenRef.current = null + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) + setUser(null) + }, []) + + const onSetupComplete = useCallback( + async (accessToken: string, refreshToken: string) => { + storeToken(accessToken) + refreshTokenRef.current = refreshToken + setNeedsSetup(false) + scheduleRefresh(accessToken) + await fetchMe() + }, + [fetchMe, scheduleRefresh], + ) + + return ( + + {children} + + ) +} diff --git a/frontend/src/hooks/useDuplicateGroupsQuery.ts b/frontend/src/hooks/useDuplicateGroupsQuery.ts index 3768975..f1f134c 100644 --- a/frontend/src/hooks/useDuplicateGroupsQuery.ts +++ b/frontend/src/hooks/useDuplicateGroupsQuery.ts @@ -11,7 +11,7 @@ export const DUPLICATE_GROUPS_QUERY_KEY = ['library', 'duplicates'] as const export function useDuplicateGroupsQuery() { return useQuery({ queryKey: DUPLICATE_GROUPS_QUERY_KEY, - queryFn: library.duplicates.groups, + queryFn: () => library.duplicates.groups(), staleTime: 30_000, }) } diff --git a/frontend/src/hooks/useLibraryStatsQuery.ts b/frontend/src/hooks/useLibraryStatsQuery.ts index db580cb..d3ed23d 100644 --- a/frontend/src/hooks/useLibraryStatsQuery.ts +++ b/frontend/src/hooks/useLibraryStatsQuery.ts @@ -13,7 +13,7 @@ export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const export function useLibraryStatsQuery() { return useQuery({ queryKey: LIBRARY_STATS_QUERY_KEY, - queryFn: library.stats, + queryFn: () => library.stats(), staleTime: 30_000, }) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e90c0e3..04998fb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -14,6 +14,61 @@ const api = axios.create({ }, }) +// ── Auth interceptors ────────────────────────────────────────────────── + +// Attach the stored JWT to every outgoing request. +api.interceptors.request.use((config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// On 401 responses, attempt one silent token refresh. If that also +// fails, clear stored credentials so the AuthContext falls back to the +// login screen on its next render. +let isRefreshing = false +let refreshSubscribers: ((token: string) => void)[] = [] + +api.interceptors.response.use( + (response) => response, + async (error) => { + const original = error.config + if (error.response?.status !== 401 || original._retry) { + return Promise.reject(error) + } + + // Skip retry for auth endpoints themselves to avoid loops. + if (original.url?.startsWith('/auth/')) { + return Promise.reject(error) + } + + original._retry = true + + if (!isRefreshing) { + isRefreshing = true + // The refresh token lives in AuthContext memory, not in + // localStorage. The interceptor can't access it directly, so we + // rely on the AuthContext's scheduled refresh to keep the access + // token fresh. If the access token is truly expired and no + // refresh has happened, we just force a logout. + localStorage.removeItem('access_token') + isRefreshing = false + // Reject — AuthContext will detect the missing token and show login. + return Promise.reject(error) + } + + // Another request is already refreshing — queue this one. + return new Promise((resolve) => { + refreshSubscribers.push((token: string) => { + original.headers.Authorization = `Bearer ${token}` + resolve(api(original)) + }) + }) + }, +) + // Source Folders API. Source roots are config-driven now (PHOTO_DIRS in // .env → bootstrap on backend startup), so the UI only reads them and // optionally renames the display label. @@ -394,15 +449,19 @@ export const library = { return response.data }, - stats: async (): Promise => { - const response = await api.get('/library/stats') + stats: async (scope?: 'global'): Promise => { + const response = await api.get('/library/stats', { + params: scope ? { scope } : undefined, + }) return response.data }, /** Maintenance / admin actions surfaced via the Settings panel. */ maintenance: { - thumbnailStats: async (): Promise => { - const response = await api.get('/library/maintenance/thumbnail-stats') + thumbnailStats: async (scope?: 'global'): Promise => { + const response = await api.get('/library/maintenance/thumbnail-stats', { + params: scope ? { scope } : undefined, + }) return response.data }, @@ -413,28 +472,30 @@ export const library = { media_types?: MediaType[] only_failed?: boolean only_pending?: boolean - } = {} + } = {}, + scope?: 'global', ): Promise => { const response = await api.post( '/library/maintenance/regenerate-thumbnails', - body + body, + { params: scope ? { scope } : undefined }, ) return response.data }, - /** Celery worker fleet diagnostics + recent task failures. Surfaced - * in the Settings panel so users can debug stuck queues without - * tailing container logs. */ - workerStatus: async (): Promise => { - const response = await api.get('/library/maintenance/worker-status') + /** Celery worker fleet diagnostics + recent task failures. */ + workerStatus: async (scope?: 'global'): Promise => { + const response = await api.get('/library/maintenance/worker-status', { + params: scope ? { scope } : undefined, + }) return response.data }, - /** Per-stage ingestion progress — thumbnails, EXIF, GPS, phash, - * embeddings, object tags, OCR, faces, face clusters, duplicate - * groups. Drives the Pipeline Progress card in Settings. */ - pipelineStats: async (): Promise => { - const response = await api.get('/library/maintenance/pipeline-stats') + /** Per-stage ingestion progress. */ + pipelineStats: async (scope?: 'global'): Promise => { + const response = await api.get('/library/maintenance/pipeline-stats', { + params: scope ? { scope } : undefined, + }) return response.data }, @@ -477,8 +538,10 @@ export const library = { /** Duplicate groups computed by app.services.duplicates.regroup_duplicates. * Drives the grouped grid view in the Duplicates section. */ duplicates: { - groups: async (): Promise => { - const response = await api.get('/library/duplicates/groups') + groups: async (scope?: 'global'): Promise => { + const response = await api.get('/library/duplicates/groups', { + params: scope ? { scope } : undefined, + }) return response.data }, }, @@ -725,4 +788,45 @@ export const discard = { }, } +// Admin API — user management (admin only) +export interface AdminUser { + id: string + username: string + email: string | null + role: 'admin' | 'user' + is_active: boolean + media_path: string + created_at: string | null + photo_count: number +} + +export const admin = { + listUsers: async (): Promise<{ users: AdminUser[]; total: number }> => { + const response = await api.get('/admin/users') + return response.data + }, + + createUser: async (data: { + username: string + password: string + role: string + }): Promise => { + const response = await api.post('/admin/users', data) + return response.data + }, + + updateUser: async ( + userId: string, + data: { role?: string; is_active?: boolean; new_password?: string }, + ): Promise => { + const response = await api.patch(`/admin/users/${userId}`, data) + return response.data + }, + + deleteUser: async (userId: string): Promise<{ status: string }> => { + const response = await api.delete(`/admin/users/${userId}`) + return response.data + }, +} + export default api \ No newline at end of file