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

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