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

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