feat: multi-user auth with per-user media isolation

Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.

Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup

Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:46:52 +02:00
parent 03a4c75e3e
commit 348e9c3585
40 changed files with 2313 additions and 440 deletions

View File

@@ -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

View File

@@ -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")

47
backend/app/auth.py Normal file
View File

@@ -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])

View File

@@ -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:

View File

@@ -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

132
backend/app/dependencies.py Normal file
View File

@@ -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

View File

@@ -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"])

View File

@@ -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',

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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'

View File

@@ -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)

View File

@@ -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"}

200
backend/app/routers/auth.py Normal file
View File

@@ -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}

View File

@@ -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()

View File

@@ -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:

View File

@@ -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}

View File

@@ -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

View File

@@ -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()

View File

@@ -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}

View File

@@ -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")

View File

@@ -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():

View File

@@ -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()

View File

@@ -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

54
backend/bootstrap.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 (
<div className="flex flex-col h-screen bg-bg text-text">
@@ -70,7 +72,6 @@ function App() {
>
<LeftSidebar
onCollapse={() => setLeftSidebarOpen(false)}
onOpenSettings={() => setSettingsOpen(true)}
/>
</div>
@@ -79,14 +80,12 @@ function App() {
* across the sidebar. relative so the KeyboardHints overlay
* centers against this column, not the viewport. */}
<div className="relative flex min-w-0 flex-1 flex-col">
<FilterBar />
<DiscardActionBar />
{!isSettings && <FilterBar />}
{!isSettings && <DiscardActionBar />}
<div className="flex-1 overflow-auto">
{/* 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' ? (
<SettingsPage />
) : currentSection === 'map' ? (
<MapView />
) : currentSection === 'duplicates' ? (
<DuplicatesView />
@@ -102,10 +101,7 @@ function App() {
<Timeline />
)}
</div>
{/* 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). */}
<KeyboardHints />
{!isSettings && <KeyboardHints />}
</div>
{/* Right Sidebar */}
@@ -127,13 +123,33 @@ function App() {
{/* Preview overlay — covers TopBar when active */}
{viewMode === 'preview' && <PreviewView />}
{/* Settings panel — admin/maintenance actions */}
<SettingsDialog
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</div>
)
}
/** Auth-gated shell: shows setup, login, or the main app. */
function App() {
return (
<AuthProvider>
<AuthGate />
</AuthProvider>
)
}
function AuthGate() {
const { user, isLoading, needsSetup } = useAuth()
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg">
<div className="text-text-muted">Loading&hellip;</div>
</div>
)
}
if (needsSetup) return <SetupPage />
if (!user) return <LoginPage />
return <MainApp />
}
export default App

View File

@@ -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<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [error, setError] = useState<string | null>(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 <div className="p-4 text-sm text-text-muted">Loading users&hellip;</div>
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text">Users</h3>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white hover:bg-accent/80"
>
<Plus className="h-3 w-3" />
Add User
</button>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="pb-1 pr-4">Username</th>
<th className="pb-1 pr-4">Role</th>
<th className="pb-1 pr-4">Photos</th>
<th className="pb-1 pr-4">Status</th>
<th className="pb-1">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-border/50">
<td className="py-1.5 pr-4">
<div className="flex items-center gap-1.5">
{u.role === 'admin' ? (
<Shield className="h-3 w-3 text-accent" />
) : (
<UserIcon className="h-3 w-3 text-text-muted" />
)}
<span className="text-text">{u.username}</span>
</div>
</td>
<td className="py-1.5 pr-4 text-text-muted">{u.role}</td>
<td className="py-1.5 pr-4 text-text-muted">
{u.photo_count.toLocaleString()}
</td>
<td className="py-1.5 pr-4">
<span
className={
u.is_active
? 'text-green-400'
: 'text-red-400'
}
>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="py-1.5">
<div className="flex gap-1">
<button
onClick={() => setEditingUser(u)}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-text"
title="Edit user"
>
<Pencil className="h-3 w-3" />
</button>
{u.is_active && (
<button
onClick={async () => {
if (!confirm(`Deactivate user "${u.username}"? Their photos will be preserved.`)) return
try {
await admin.deleteUser(u.id)
fetchUsers()
} catch (err: any) {
setError(err.response?.data?.detail ?? 'Failed to deactivate user.')
}
}}
className="rounded p-1 text-text-muted hover:bg-bg hover:text-red-400"
title="Deactivate user"
>
<UserX className="h-3 w-3" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{showCreate && (
<CreateUserModal
onClose={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false)
fetchUsers()
}}
/>
)}
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSaved={() => {
setEditingUser(null)
fetchUsers()
}}
/>
)}
</div>
)
}
// ── 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<string | null>(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 (
<ModalOverlay onClose={onClose} title="Add User">
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<div className="space-y-3">
<Field label="Username">
<input
value={username}
onChange={(e) => 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
/>
</Field>
<Field label="Password">
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</Field>
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating\u2026' : 'Create'}
</button>
</div>
</ModalOverlay>
)
}
// ── 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<string | null>(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 (
<ModalOverlay onClose={onClose} title={`Edit: ${user.username}`}>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
<div className="space-y-3">
<Field label="Role">
<select
value={role}
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text outline-none focus:border-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</Field>
<Field label="New Password (leave blank to keep current)">
<input
type="password"
value={newPassword}
onChange={(e) => 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"
/>
</Field>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded border border-border px-3 py-1 text-xs text-text hover:bg-bg"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded bg-accent px-3 py-1 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Saving\u2026' : 'Save'}
</button>
</div>
</ModalOverlay>
)
}
// ── Shared helpers ─────────────────────────────────────────────────────
function ModalOverlay({
onClose: _onClose,
title,
children,
}: {
onClose: () => void
title: string
children: React.ReactNode
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={_onClose}>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h4 className="mb-3 text-sm font-semibold text-text">{title}</h4>
{children}
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<label className="block text-[11px] text-text-muted">{label}</label>
{children}
</div>
)
}

View File

@@ -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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
>
<h1 className="text-center text-xl font-semibold text-text">
Sign in to Mulita
</h1>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="space-y-1">
<label htmlFor="login-user" className="block text-sm text-text-muted">
Username
</label>
<input
id="login-user"
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="login-pass" className="block text-sm text-text-muted">
Password
</label>
<input
id="login-pass"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Signing in\u2026' : 'Sign In'}
</button>
</form>
</div>
)
}

View File

@@ -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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl"
>
<div className="space-y-1 text-center">
<h1 className="text-xl font-semibold text-text">Welcome to Mulita</h1>
<p className="text-sm text-text-muted">
Create your admin account to get started.
</p>
</div>
{error && (
<div className="rounded bg-red-900/30 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<div className="space-y-1">
<label htmlFor="setup-user" className="block text-sm text-text-muted">
Username
</label>
<input
id="setup-user"
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-pass" className="block text-sm text-text-muted">
Password
</label>
<input
id="setup-pass"
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="setup-confirm" className="block text-sm text-text-muted">
Confirm Password
</label>
<input
id="setup-confirm"
type="password"
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/80 disabled:opacity-50"
>
{loading ? 'Creating account\u2026' : 'Create Admin Account'}
</button>
</form>
</div>
)
}

View File

@@ -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<SettingsTab>('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<ScanStatus>({
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 <T,>(
@@ -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 (
<div className="fixed inset-0 z-[2000]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<h2 className="text-base font-semibold text-text">Settings</h2>
<button
onClick={onClose}
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
title="Close (Esc)"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex h-full flex-col">
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={clsx(
'rounded px-3 py-1 text-xs font-medium transition-colors',
activeTab === tab.id
? 'bg-primary/20 text-primary'
: 'text-text-muted hover:bg-surface-2 hover:text-text',
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-5">
{/* ----------------------------------------------------- */}
{/* Library overview */}
{/* ----------------------------------------------------- */}
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-5">
<div className="mx-auto max-w-2xl">
{activeTab === 'library' && (<>
<Section
icon={<Database className="h-4 w-4" />}
title="Library"
@@ -334,9 +301,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Pipeline progress — per-stage done/total */}
{/* ----------------------------------------------------- */}
<Section
icon={<Activity className="h-4 w-4" />}
title="Pipeline progress"
@@ -379,9 +343,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
)}
</Section>
{/* ----------------------------------------------------- */}
{/* Duplicate detection */}
{/* ----------------------------------------------------- */}
<Section
icon={<Copy className="h-4 w-4" />}
title="Duplicates"
@@ -433,9 +394,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Thumbnail maintenance */}
{/* ----------------------------------------------------- */}
<Section
icon={<ImageIcon className="h-4 w-4" />}
title="Thumbnails"
@@ -524,9 +483,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
</Section>
{/* ----------------------------------------------------- */}
{/* Worker fleet diagnostics */}
{/* ----------------------------------------------------- */}
<Section
icon={<Cpu className="h-4 w-4" />}
title="Workers"
@@ -848,9 +804,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
)}
</Section>
{/* ----------------------------------------------------- */}
{/* Data integrity */}
{/* ----------------------------------------------------- */}
<Section
icon={<Wrench className="h-4 w-4" />}
title="Maintenance"
@@ -876,7 +829,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</ActionButton>
</div>
</Section>
</div>
</>)}
{activeTab === 'users' && isAdmin && (
<Section
icon={<Shield className="h-4 w-4" />}
title="User Management"
>
<UserManagement />
</Section>
)}
</div>
</div>
</div>

View File

@@ -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<Set<string>>(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) {
<HeapsPanel />
</div>
{/* 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. */}
<div className="border-t border-border p-1.5">
<button
onClick={onOpenSettings}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted hover:bg-surface-2 hover:text-text"
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
{/* Bottom panel — user identity + settings, pinned below the tree. */}
<div className="border-t border-border p-1.5 space-y-0.5">
{/* User row */}
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
<span className="flex-1 truncate text-text">{user?.username}</span>
{isAdmin && (
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
<Shield className="inline h-2.5 w-2.5" />
</span>
)}
<button
onClick={logout}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-red-400 flex-shrink-0"
title="Sign out"
>
<LogOut className="h-3 w-3" />
</button>
</div>
{/* Settings — admin only, navigates to the settings section */}
{isAdmin && (
<button
onClick={() => navigateToSection('settings', {})}
className={clsx(
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
)}
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
Settings
</button>
)}
</div>
<DeleteFolderDialog

View File

@@ -96,22 +96,20 @@ export function TopBar({
{MULIMAGO_ASCII}
</pre>
</div>
<div className="flex h-full items-end gap-2 self-stretch pb-1">
<div className="flex items-center gap-2">
<span className="text-[10px] font-serif text-black/80">
Built with hubris {toRoman(new Date().getFullYear())}
</span>
{!rightSidebarOpen && (
<button
onClick={onExpandRight}
className="self-center rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
className="rounded bg-black/30 p-1.5 text-text-muted backdrop-blur-sm transition-colors hover:bg-black/50 hover:text-text"
title="Expand panel (I)"
aria-label="Expand right panel"
>
<PanelRightOpen className="h-4 w-4" />
</button>
)}
<span
className="text-[10px] font-serif text-black/80"
>
Built with hubris {toRoman(new Date().getFullYear())}
</span>
</div>
</header>
)

View File

@@ -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<void>
logout: () => void
/** Called after the setup endpoint creates the first admin. */
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(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<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
// Keep refresh token in memory only (not localStorage).
const refreshTokenRef = useRef<string | null>(null)
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<AuthContext.Provider
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
>
{children}
</AuthContext.Provider>
)
}

View File

@@ -11,7 +11,7 @@ export const DUPLICATE_GROUPS_QUERY_KEY = ['library', 'duplicates'] as const
export function useDuplicateGroupsQuery() {
return useQuery<DuplicateGroupsResponse>({
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
queryFn: library.duplicates.groups,
queryFn: () => library.duplicates.groups(),
staleTime: 30_000,
})
}

View File

@@ -13,7 +13,7 @@ export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const
export function useLibraryStatsQuery() {
return useQuery<LibraryStats>({
queryKey: LIBRARY_STATS_QUERY_KEY,
queryFn: library.stats,
queryFn: () => library.stats(),
staleTime: 30_000,
})
}

View File

@@ -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<LibraryStats> => {
const response = await api.get('/library/stats')
stats: async (scope?: 'global'): Promise<LibraryStats> => {
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<ThumbnailStats> => {
const response = await api.get('/library/maintenance/thumbnail-stats')
thumbnailStats: async (scope?: 'global'): Promise<ThumbnailStats> => {
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<RegenerateResult> => {
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<WorkerStatus> => {
const response = await api.get('/library/maintenance/worker-status')
/** Celery worker fleet diagnostics + recent task failures. */
workerStatus: async (scope?: 'global'): Promise<WorkerStatus> => {
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<PipelineStats> => {
const response = await api.get('/library/maintenance/pipeline-stats')
/** Per-stage ingestion progress. */
pipelineStats: async (scope?: 'global'): Promise<PipelineStats> => {
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<DuplicateGroupsResponse> => {
const response = await api.get('/library/duplicates/groups')
groups: async (scope?: 'global'): Promise<DuplicateGroupsResponse> => {
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<AdminUser> => {
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<AdminUser> => {
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