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

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