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:
14
.env.example
14
.env.example
@@ -36,6 +36,20 @@ BACKEND_PORT=8001
|
|||||||
REDIS_PORT=6379
|
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 ─────────────────────────────────────────────────────────────────────
|
# ── CORS ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Comma-separated list of allowed origins for direct browser access to the
|
# Comma-separated list of allowed origins for direct browser access to the
|
||||||
|
|||||||
144
backend/alembic/versions/0009_users_and_auth.py
Normal file
144
backend/alembic/versions/0009_users_and_auth.py
Normal 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
47
backend/app/auth.py
Normal 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])
|
||||||
@@ -127,6 +127,17 @@ class Settings(BaseSettings):
|
|||||||
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
# ERROR, CRITICAL). Bumped from INFO when chasing a problem.
|
||||||
log_level: str = Field(default="INFO", env="LOG_LEVEL")
|
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
|
@property
|
||||||
def cors_origins(self) -> list[str]:
|
def cors_origins(self) -> list[str]:
|
||||||
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
"""Parse the ALLOWED_ORIGINS env var into a list. Accepts:
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ async def init_db():
|
|||||||
"""Initialize database, create tables if they don't exist"""
|
"""Initialize database, create tables if they don't exist"""
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
# Import all models to register them with Base
|
# 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
|
# Postgres: ensure pgvector is available before create_all touches
|
||||||
# any Vector columns (added in later PRs but the extension is cheap
|
# any Vector columns (added in later PRs but the extension is cheap
|
||||||
|
|||||||
132
backend/app/dependencies.py
Normal file
132
backend/app/dependencies.py
Normal 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
|
||||||
@@ -11,7 +11,7 @@ import os
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
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.scanner import start_initial_scan, bootstrap_default_source_root
|
||||||
from app.services.cleanup import cleanup_data_integrity
|
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")
|
app.mount("/thumbs", StaticFiles(directory="/data/thumbs"), name="thumbs")
|
||||||
|
|
||||||
# Include routers
|
# 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(photos.router, prefix="/api/v1/photos", tags=["photos"])
|
||||||
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
|
||||||
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
app.include_router(heaps.router, prefix="/api/v1/heaps", tags=["heaps"])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Database models for Mulita
|
Database models for Mulita
|
||||||
"""
|
"""
|
||||||
|
from app.models.user import User
|
||||||
from app.models.photos import Photo
|
from app.models.photos import Photo
|
||||||
from app.models.folders import Folder, SourceRoot
|
from app.models.folders import Folder, SourceRoot
|
||||||
from app.models.tags import Tag, PhotoTag
|
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
|
from app.models.face_embedding import FaceEmbedding
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'User',
|
||||||
'Photo',
|
'Photo',
|
||||||
'Folder',
|
'Folder',
|
||||||
'SourceRoot',
|
'SourceRoot',
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ from app.database import Base
|
|||||||
|
|
||||||
class SourceRoot(Base):
|
class SourceRoot(Base):
|
||||||
__tablename__ = 'source_roots'
|
__tablename__ = 'source_roots'
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
path = Column(String, unique=True, nullable=False)
|
path = Column(String, unique=True, nullable=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
added_at = Column(DateTime, server_default=func.now())
|
added_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
# Owner
|
||||||
|
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
folders = relationship("Folder", back_populates="source_root")
|
folders = relationship("Folder", back_populates="source_root")
|
||||||
|
|
||||||
@@ -28,6 +31,9 @@ class Folder(Base):
|
|||||||
path = Column(String, unique=True, nullable=False)
|
path = Column(String, unique=True, nullable=False)
|
||||||
parent_id = Column(String, ForeignKey('folders.id'))
|
parent_id = Column(String, ForeignKey('folders.id'))
|
||||||
source_root_id = Column(String, ForeignKey('source_roots.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)
|
photo_count = Column(Integer, default=0)
|
||||||
last_scanned = Column(DateTime)
|
last_scanned = Column(DateTime)
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ heap_photos = Table(
|
|||||||
|
|
||||||
class Heap(Base):
|
class Heap(Base):
|
||||||
__tablename__ = 'heaps'
|
__tablename__ = 'heaps'
|
||||||
|
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, onupdate=func.now())
|
updated_at = Column(DateTime, onupdate=func.now())
|
||||||
is_active = Column(Boolean, default=False) # For active heap feature
|
is_active = Column(Boolean, default=False) # For active heap feature
|
||||||
|
|
||||||
|
# Owner
|
||||||
|
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
|
photos = relationship("Photo", secondary=heap_photos, backref="heaps")
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ class Photo(Base):
|
|||||||
# Primary key
|
# Primary key
|
||||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
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
|
# File information
|
||||||
filepath = Column(String, unique=True, nullable=False)
|
filepath = Column(String, unique=True, nullable=False)
|
||||||
filename = Column(String, nullable=False)
|
filename = Column(String, nullable=False)
|
||||||
|
|||||||
@@ -30,13 +30,16 @@ photo_tags = Table(
|
|||||||
class Tag(Base):
|
class Tag(Base):
|
||||||
__tablename__ = 'tags'
|
__tablename__ = 'tags'
|
||||||
__table_args__ = (
|
__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()))
|
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
color = Column(String) # Hex color code for UI display
|
color = Column(String) # Hex color code for UI display
|
||||||
|
|
||||||
|
# Owner
|
||||||
|
user_id = Column(String, ForeignKey('users.id'), nullable=True, index=True)
|
||||||
|
|
||||||
# Tag classification
|
# Tag classification
|
||||||
kind = Column(String, nullable=False, default='user', index=True)
|
kind = Column(String, nullable=False, default='user', index=True)
|
||||||
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
|
# kind values: 'user' | 'object' | 'scene' | 'face_cluster'
|
||||||
|
|||||||
23
backend/app/models/user.py
Normal file
23
backend/app/models/user.py
Normal 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)
|
||||||
260
backend/app/routers/admin.py
Normal file
260
backend/app/routers/admin.py
Normal 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
200
backend/app/routers/auth.py
Normal 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}
|
||||||
@@ -9,25 +9,27 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
|
from app.models.user import User
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.get("")
|
@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"""
|
"""List discarded photos"""
|
||||||
result = await db.execute(
|
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()
|
photos = result.scalars().all()
|
||||||
return photos
|
return photos
|
||||||
|
|
||||||
@router.post("/restore")
|
@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"""
|
"""Restore photos from the discard pile"""
|
||||||
result = await db.execute(
|
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()
|
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)}
|
return {"status": "success", "restored": len(photos)}
|
||||||
|
|
||||||
@router.delete("/empty")
|
@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
|
"""Permanently delete all discarded photos and unlink their files from
|
||||||
disk. Failures on individual files are logged but don't abort the batch.
|
disk. Failures on individual files are logged but don't abort the batch.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
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()
|
photos = result.scalars().all()
|
||||||
return await _permanently_delete(db, photos)
|
return await _permanently_delete(db, photos)
|
||||||
@@ -54,6 +56,7 @@ async def empty_discard(db: AsyncSession = Depends(get_db)):
|
|||||||
async def delete_discarded(
|
async def delete_discarded(
|
||||||
photo_ids: list[str] = Body(..., embed=True),
|
photo_ids: list[str] = Body(..., embed=True),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Permanently delete a specific subset of discarded photos. The photos
|
"""Permanently delete a specific subset of discarded photos. The photos
|
||||||
must already be in the discard pile — non-discarded ids are skipped so
|
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}
|
return {"status": "success", "deleted": 0, "file_errors": 0}
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Photo).where(
|
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()
|
photos = result.scalars().all()
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Folder, SourceRoot, Photo
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -48,10 +50,10 @@ def _validate_folder_name(name: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
@router.get("")
|
@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 all source folders"""
|
||||||
# Get source roots instead of regular 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()
|
source_roots = result.scalars().all()
|
||||||
|
|
||||||
folders_list = []
|
folders_list = []
|
||||||
@@ -73,7 +75,7 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
|||||||
return {"folders": folders_list}
|
return {"folders": folders_list}
|
||||||
|
|
||||||
@router.get("/tree")
|
@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
|
"""Recursive folder tree, one root per active SourceRoot. The tree
|
||||||
starts at the Folder row matching the SourceRoot.path (the scanner
|
starts at the Folder row matching the SourceRoot.path (the scanner
|
||||||
creates one for every walked directory), with the SourceRoot's
|
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.
|
creates as a parent walk) are skipped via path-prefix filtering.
|
||||||
"""
|
"""
|
||||||
sr_result = await db.execute(
|
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()
|
source_roots = sr_result.scalars().all()
|
||||||
|
|
||||||
@@ -196,6 +198,7 @@ async def rename_folder(
|
|||||||
folder_id: str,
|
folder_id: str,
|
||||||
body: FolderRename,
|
body: FolderRename,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Rename a folder. Two cases:
|
"""Rename a folder. Two cases:
|
||||||
|
|
||||||
@@ -212,7 +215,7 @@ async def rename_folder(
|
|||||||
|
|
||||||
# Try SourceRoot first (display-only rename).
|
# Try SourceRoot first (display-only rename).
|
||||||
sr_result = await db.execute(
|
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()
|
source_root = sr_result.scalar_one_or_none()
|
||||||
if source_root:
|
if source_root:
|
||||||
@@ -225,14 +228,11 @@ async def rename_folder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Otherwise it's a Folder row.
|
# Otherwise it's a Folder row.
|
||||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
folder = await get_user_folder(folder_id, current_user, db)
|
||||||
folder = folder_result.scalar_one_or_none()
|
|
||||||
if not folder:
|
|
||||||
raise HTTPException(status_code=404, detail="Folder not found")
|
|
||||||
|
|
||||||
# Refuse to rename the bare source root mount through here.
|
# Refuse to rename the bare source root mount through here.
|
||||||
sr_check = await db.execute(
|
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()
|
sr = sr_check.scalar_one_or_none()
|
||||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
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)
|
@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 a new sub-folder under an existing Folder. Mirrors the
|
||||||
create to disk so the next scan sees it. Body: { name, parent_id }.
|
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
|
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)
|
name = _validate_folder_name(body.name)
|
||||||
|
|
||||||
parent_result = await db.execute(
|
parent = await get_user_folder(body.parent_id, current_user, db)
|
||||||
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")
|
|
||||||
|
|
||||||
new_path = os.path.join(parent.path, name)
|
new_path = os.path.join(parent.path, name)
|
||||||
if os.path.exists(new_path):
|
if os.path.exists(new_path):
|
||||||
@@ -323,6 +318,7 @@ async def create_folder(body: FolderCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
name=name,
|
name=name,
|
||||||
path=new_path,
|
path=new_path,
|
||||||
source_root_id=parent.source_root_id,
|
source_root_id=parent.source_root_id,
|
||||||
|
user_id=current_user.id,
|
||||||
photo_count=0,
|
photo_count=0,
|
||||||
)
|
)
|
||||||
db.add(new_folder)
|
db.add(new_folder)
|
||||||
@@ -341,6 +337,7 @@ async def delete_folder(
|
|||||||
folder_id: str,
|
folder_id: str,
|
||||||
mode: Literal['discard', 'permanent'] = Query('discard'),
|
mode: Literal['discard', 'permanent'] = Query('discard'),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Delete a folder. Behavior depends on mode:
|
"""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
|
Refuses to delete the bare source-root mount in either mode (deleting
|
||||||
the docker mount through the UI would be a footgun).
|
the docker mount through the UI would be a footgun).
|
||||||
"""
|
"""
|
||||||
folder_result = await db.execute(select(Folder).where(Folder.id == folder_id))
|
folder = await get_user_folder(folder_id, current_user, db)
|
||||||
folder = folder_result.scalar_one_or_none()
|
|
||||||
if not folder:
|
|
||||||
raise HTTPException(status_code=404, detail="Folder not found")
|
|
||||||
|
|
||||||
sr_check = await db.execute(
|
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()
|
sr = sr_check.scalar_one_or_none()
|
||||||
if sr and os.path.normpath(folder.path) == os.path.normpath(sr.path):
|
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,
|
folder_id: str,
|
||||||
body: FolderHide,
|
body: FolderHide,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Toggle the "hide from views" flag on a folder or source root.
|
"""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.
|
# SourceRoot path — resolve to the Folder row at the mount point.
|
||||||
sr_result = await db.execute(
|
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()
|
source_root = sr_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -511,6 +506,7 @@ async def set_folder_hidden(
|
|||||||
root_folder_result = await db.execute(
|
root_folder_result = await db.execute(
|
||||||
select(Folder).where(
|
select(Folder).where(
|
||||||
Folder.source_root_id == source_root.id,
|
Folder.source_root_id == source_root.id,
|
||||||
|
Folder.user_id == current_user.id,
|
||||||
Folder.path == os.path.normpath(source_root.path),
|
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.",
|
detail="Source root has no indexed Folder row yet; scan first.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
folder_result = await db.execute(
|
folder = await get_user_folder(folder_id, current_user, db)
|
||||||
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.is_hidden = bool(body.hidden)
|
folder.is_hidden = bool(body.hidden)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -547,11 +538,11 @@ async def set_folder_hidden(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{folder_id}/scan")
|
@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"""
|
"""Trigger manual re-scan of source root folder"""
|
||||||
from app.tasks.celery import celery_app
|
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()
|
source_root = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not source_root:
|
if not source_root:
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from app.database import get_db
|
|||||||
from app.models import Heap, Photo, Folder
|
from app.models import Heap, Photo, Folder
|
||||||
from app.models.folders import SourceRoot
|
from app.models.folders import SourceRoot
|
||||||
from app.models.heaps import heap_photos
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -48,7 +50,10 @@ class HeapConvertBody(BaseModel):
|
|||||||
# ── Endpoints ─────────────────────────────────────────────────────────────
|
# ── Endpoints ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("")
|
@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."""
|
"""List all heaps with photo counts."""
|
||||||
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
# LEFT JOIN heap_photos and group so we can return counts in one query.
|
||||||
count_subq = (
|
count_subq = (
|
||||||
@@ -63,6 +68,7 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Heap, count_subq.c.photo_count)
|
select(Heap, count_subq.c.photo_count)
|
||||||
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
.outerjoin(count_subq, Heap.id == count_subq.c.heap_id)
|
||||||
|
.where(Heap.user_id == current_user.id)
|
||||||
.order_by(Heap.created_at.asc())
|
.order_by(Heap.created_at.asc())
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
@@ -82,12 +88,16 @@ async def list_heaps(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@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."""
|
"""Create a new heap."""
|
||||||
name = (body.name or "").strip()
|
name = (body.name or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="Heap name is required")
|
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)
|
db.add(heap)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(heap)
|
await db.refresh(heap)
|
||||||
@@ -103,14 +113,14 @@ async def create_heap(body: HeapCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.patch("/{heap_id}")
|
@router.patch("/{heap_id}")
|
||||||
async def update_heap(
|
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
|
"""Rename a heap and/or toggle active state. Setting is_active=true on
|
||||||
one heap deactivates all others (single-active invariant)."""
|
one heap deactivates all others (single-active invariant)."""
|
||||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
heap = await get_user_heap(heap_id, current_user, db)
|
||||||
heap = result.scalar_one_or_none()
|
|
||||||
if not heap:
|
|
||||||
raise HTTPException(status_code=404, detail="Heap not found")
|
|
||||||
|
|
||||||
if body.name is not None:
|
if body.name is not None:
|
||||||
name = body.name.strip()
|
name = body.name.strip()
|
||||||
@@ -120,8 +130,12 @@ async def update_heap(
|
|||||||
|
|
||||||
if body.is_active is not None:
|
if body.is_active is not None:
|
||||||
if body.is_active:
|
if body.is_active:
|
||||||
# Clear active flag on all other heaps in one statement
|
# Clear active flag on all other heaps for this user
|
||||||
await db.execute(update(Heap).values(is_active=False))
|
await db.execute(
|
||||||
|
update(Heap)
|
||||||
|
.where(Heap.user_id == current_user.id)
|
||||||
|
.values(is_active=False)
|
||||||
|
)
|
||||||
heap.is_active = True
|
heap.is_active = True
|
||||||
else:
|
else:
|
||||||
heap.is_active = False
|
heap.is_active = False
|
||||||
@@ -138,17 +152,18 @@ async def update_heap(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{heap_id}/duplicate", status_code=201)
|
@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
|
"""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 —
|
new heap is named "{original} (copy)" and is never the active target —
|
||||||
duplicating shouldn't quietly steal the user's T-key destination.
|
duplicating shouldn't quietly steal the user's T-key destination.
|
||||||
"""
|
"""
|
||||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
source = await get_user_heap(heap_id, current_user, db)
|
||||||
source = result.scalar_one_or_none()
|
|
||||||
if not source:
|
|
||||||
raise HTTPException(status_code=404, detail="Heap not found")
|
|
||||||
|
|
||||||
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)
|
db.add(new_heap)
|
||||||
await db.flush() # populate new_heap.id without committing yet
|
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)
|
@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
|
"""Delete a heap. Photos themselves are unaffected — only the membership
|
||||||
rows in heap_photos cascade-delete."""
|
rows in heap_photos cascade-delete."""
|
||||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
heap = await get_user_heap(heap_id, current_user, db)
|
||||||
heap = result.scalar_one_or_none()
|
|
||||||
if not heap:
|
|
||||||
raise HTTPException(status_code=404, detail="Heap not found")
|
|
||||||
await db.delete(heap)
|
await db.delete(heap)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{heap_id}/photo_ids")
|
@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
|
"""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
|
to maintain a fast client-side membership lookup for the active heap
|
||||||
(for the basket affordance on thumbnails) without fetching full photo
|
(for the basket affordance on thumbnails) without fetching full photo
|
||||||
records."""
|
records."""
|
||||||
|
await get_user_heap(heap_id, current_user, db)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(heap_photos.c.photo_id).where(heap_photos.c.heap_id == heap_id)
|
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")
|
@router.post("/{heap_id}/photos")
|
||||||
async def add_photos_to_heap(
|
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
|
"""Add photos to a heap. Idempotent: re-adding existing members is a
|
||||||
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
no-op (handled by an INSERT OR IGNORE-style filter on duplicates)."""
|
||||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
await get_user_heap(heap_id, current_user, db)
|
||||||
heap = result.scalar_one_or_none()
|
|
||||||
if not heap:
|
|
||||||
raise HTTPException(status_code=404, detail="Heap not found")
|
|
||||||
|
|
||||||
if not body.photo_ids:
|
if not body.photo_ids:
|
||||||
return {"status": "success", "added": 0}
|
return {"status": "success", "added": 0}
|
||||||
@@ -241,6 +262,7 @@ async def convert_heap_to_folder(
|
|||||||
heap_id: str,
|
heap_id: str,
|
||||||
body: HeapConvertBody,
|
body: HeapConvertBody,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Convert a heap into a folder by moving (or copying) every member
|
"""Convert a heap into a folder by moving (or copying) every member
|
||||||
photo into the target directory. Optionally deletes the heap row at
|
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
|
target_id may be a Folder id or a SourceRoot id (matches the
|
||||||
/photos/move convention so the same dropdown can populate it).
|
/photos/move convention so the same dropdown can populate it).
|
||||||
"""
|
"""
|
||||||
heap_result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
heap = await get_user_heap(heap_id, current_user, db)
|
||||||
heap = heap_result.scalar_one_or_none()
|
|
||||||
if not heap:
|
|
||||||
raise HTTPException(status_code=404, detail="Heap not found")
|
|
||||||
|
|
||||||
# Resolve target_id → (target_dir, target_folder)
|
# Resolve target_id → (target_dir, target_folder)
|
||||||
sr_check = await db.execute(
|
sr_check = await db.execute(
|
||||||
@@ -402,13 +421,13 @@ async def convert_heap_to_folder(
|
|||||||
|
|
||||||
@router.delete("/{heap_id}/photos")
|
@router.delete("/{heap_id}/photos")
|
||||||
async def remove_photos_from_heap(
|
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."""
|
"""Remove photos from a heap. Removing a non-member is a no-op."""
|
||||||
result = await db.execute(select(Heap).where(Heap.id == heap_id))
|
await get_user_heap(heap_id, current_user, db)
|
||||||
heap = result.scalar_one_or_none()
|
|
||||||
if not heap:
|
|
||||||
raise HTTPException(status_code=404, detail="Heap not found")
|
|
||||||
|
|
||||||
if not body.photo_ids:
|
if not body.photo_ids:
|
||||||
return {"status": "success", "removed": 0}
|
return {"status": "success", "removed": 0}
|
||||||
|
|||||||
@@ -11,42 +11,42 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from pydantic import BaseModel, Field
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
|
from app.models.user import User
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
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
|
# Media types we accept in the regenerate-thumbnails request body. Mirrors
|
||||||
# the values produced by `app.tasks.scan.get_media_type`.
|
# the values produced by `app.tasks.scan.get_media_type`.
|
||||||
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
|
_VALID_MEDIA_TYPES = {'photo', 'raw', 'heic', 'video'}
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
async def get_library_stats(
|
||||||
"""Get library statistics + per-section counts. Each section count
|
db: AsyncSession = Depends(get_db),
|
||||||
matches the filter the sidebar applies when you click it, so the
|
current_user: User = Depends(get_current_user),
|
||||||
sidebar badges and the timeline below them stay in sync.
|
scope: str | None = Query(None),
|
||||||
|
):
|
||||||
- all_photos: non-discarded photos + videos (matches the All
|
"""Get library statistics. Pass ?scope=global (admin only) for
|
||||||
Photos section's default filter)
|
cross-user totals (used by the Settings page)."""
|
||||||
- rated: non-discarded with rating >= 1
|
owner = _owner_filter(current_user, scope)
|
||||||
- colored: non-discarded with a color_label set (matches the
|
visible = owner & (Photo.is_discarded.is_(False)) & (Photo.is_hidden.is_(False))
|
||||||
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))
|
|
||||||
|
|
||||||
all_photos_count = (
|
all_photos_count = (
|
||||||
await db.execute(select(func.count(Photo.id)).where(visible))
|
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 = (
|
discarded_count = (
|
||||||
await db.execute(
|
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
|
).scalar() or 0
|
||||||
|
|
||||||
@@ -92,17 +92,18 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
|||||||
photo_count = (
|
photo_count = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(func.count(Photo.id)).where(
|
select(func.count(Photo.id)).where(
|
||||||
|
owner,
|
||||||
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
Photo.media_type.in_(['photo', 'heic', 'raw'])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalar() or 0
|
).scalar() or 0
|
||||||
video_count = (
|
video_count = (
|
||||||
await db.execute(
|
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
|
).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 {
|
return {
|
||||||
"all_photos": all_photos_count,
|
"all_photos": all_photos_count,
|
||||||
@@ -118,7 +119,7 @@ async def get_library_stats(db: AsyncSession = Depends(get_db)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/scan")
|
@router.post("/scan")
|
||||||
async def trigger_scan():
|
async def trigger_scan(current_user: User = Depends(get_current_user)):
|
||||||
"""Trigger full library re-scan"""
|
"""Trigger full library re-scan"""
|
||||||
from app.tasks.scan import scan_all_source_roots
|
from app.tasks.scan import scan_all_source_roots
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ async def trigger_scan():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/backfill-gps")
|
@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
|
"""Re-run EXIF metadata extraction on every photo that's still missing
|
||||||
GPS coordinates. Useful after fixing the EXIF parser, or any time the
|
GPS coordinates. Useful after fixing the EXIF parser, or any time the
|
||||||
Map view looks emptier than expected. Returns immediately — work runs
|
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"}
|
return {"status": "success", "message": "GPS backfill queued"}
|
||||||
|
|
||||||
@router.get("/scan/status")
|
@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"""
|
"""Get current scan status"""
|
||||||
import redis
|
import redis
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -187,20 +188,27 @@ class RegenerateThumbnailsRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/thumbnail-stats")
|
@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
|
"""Counts of photos by processing_status, plus a media-type breakdown
|
||||||
so the Settings panel can show the user what's outstanding."""
|
so the Settings panel can show the user what's outstanding."""
|
||||||
|
owner = _owner_filter(current_user, scope)
|
||||||
status_rows = (
|
status_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Photo.processing_status, func.count(Photo.id)).group_by(
|
select(Photo.processing_status, func.count(Photo.id))
|
||||||
Photo.processing_status
|
.where(owner)
|
||||||
)
|
.group_by(Photo.processing_status)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
media_rows = (
|
media_rows = (
|
||||||
await db.execute(
|
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()
|
).all()
|
||||||
|
|
||||||
@@ -222,6 +230,8 @@ async def get_thumbnail_stats(db: AsyncSession = Depends(get_db)):
|
|||||||
async def regenerate_thumbnails(
|
async def regenerate_thumbnails(
|
||||||
body: RegenerateThumbnailsRequest,
|
body: RegenerateThumbnailsRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
"""Reset matching photos' on-disk thumbnail directories and re-queue
|
||||||
Celery thumbnail generation. Used by the Settings panel for the
|
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
|
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.
|
# Validate media_types early so a typo can't silently match nothing.
|
||||||
media_types = body.media_types
|
media_types = body.media_types
|
||||||
if media_types is not None:
|
if media_types is not None:
|
||||||
@@ -244,7 +256,7 @@ async def regenerate_thumbnails(
|
|||||||
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
|
f"Allowed: {sorted(_VALID_MEDIA_TYPES)}",
|
||||||
}
|
}
|
||||||
|
|
||||||
query = select(Photo)
|
query = select(Photo).where(owner)
|
||||||
if media_types:
|
if media_types:
|
||||||
query = query.where(Photo.media_type.in_(media_types))
|
query = query.where(Photo.media_type.in_(media_types))
|
||||||
if body.only_failed:
|
if body.only_failed:
|
||||||
@@ -297,7 +309,11 @@ async def regenerate_thumbnails(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/worker-status")
|
@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.
|
"""Diagnostics for the Celery worker fleet + recent task failures.
|
||||||
|
|
||||||
Surfaced in the Settings panel so the user can spot a stuck queue or
|
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.
|
things failed without opening the DB.
|
||||||
- broker_ok: bool — could we even reach Redis?
|
- broker_ok: bool — could we even reach Redis?
|
||||||
"""
|
"""
|
||||||
|
owner = _owner_filter(current_user, scope)
|
||||||
from app.tasks.celery import celery_app
|
from app.tasks.celery import celery_app
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
import redis as _redis
|
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 ----------------------
|
# ----- Recent task failures from the photos table ----------------------
|
||||||
failed_total = (
|
failed_total = (
|
||||||
await db.execute(
|
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
|
).scalar() or 0
|
||||||
|
|
||||||
@@ -411,7 +428,7 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
|||||||
Photo.processing_error,
|
Photo.processing_error,
|
||||||
Photo.updated_at,
|
Photo.updated_at,
|
||||||
)
|
)
|
||||||
.where(Photo.processing_status == 'failed')
|
.where(owner, Photo.processing_status == 'failed')
|
||||||
.order_by(Photo.updated_at.desc().nullslast())
|
.order_by(Photo.updated_at.desc().nullslast())
|
||||||
.limit(20)
|
.limit(20)
|
||||||
)
|
)
|
||||||
@@ -454,7 +471,11 @@ async def get_worker_status(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/pipeline-stats")
|
@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.
|
"""Per-stage progress across the ingestion pipeline.
|
||||||
|
|
||||||
Returns a `{stage_key: {done, total, label}}` map so the Settings
|
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 import Embedding, FaceEmbedding, OCRText
|
||||||
from app.models.tags import photo_tags # association Table, not a model
|
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):
|
async def scalar_count(query):
|
||||||
return (await db.execute(query)).scalar() or 0
|
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")
|
@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
|
"""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
|
source root. Surfaced in Settings so the user can see a number before
|
||||||
pulling the trigger on prune-missing. Cheap enough to call freely."""
|
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")
|
@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.
|
"""Actually delete the orphaned photo rows reported by /missing-stats.
|
||||||
Common cause: PHOTO_DIRS in .env was repointed at a different library
|
Common cause: PHOTO_DIRS in .env was repointed at a different library
|
||||||
leaving every old row dangling. Skips any photo whose source root
|
leaving every old row dangling. Skips any photo whose source root
|
||||||
@@ -677,7 +699,7 @@ async def run_prune_missing():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/cleanup")
|
@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
|
"""Re-run the source-roots / folders / photos data-integrity cleanup
|
||||||
that normally only runs on backend startup. Idempotent."""
|
that normally only runs on backend startup. Idempotent."""
|
||||||
from app.services.cleanup import cleanup_data_integrity
|
from app.services.cleanup import cleanup_data_integrity
|
||||||
@@ -695,7 +717,11 @@ async def run_data_integrity_cleanup():
|
|||||||
# ─────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/duplicates/groups")
|
@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.
|
"""Return every duplicate group with its members.
|
||||||
|
|
||||||
Drives the frontend grouped grid view in the Duplicates section. One
|
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)
|
duplicates that the perceptual hash trivially caught)
|
||||||
* "similar" — members differ at the byte level but match perceptually
|
* "similar" — members differ at the byte level but match perceptually
|
||||||
"""
|
"""
|
||||||
|
owner = _owner_filter(current_user, scope)
|
||||||
rows = (
|
rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(
|
select(
|
||||||
@@ -723,6 +750,7 @@ async def get_duplicate_groups(db: AsyncSession = Depends(get_db)):
|
|||||||
Photo.media_type,
|
Photo.media_type,
|
||||||
Photo.duplicate_group_id,
|
Photo.duplicate_group_id,
|
||||||
)
|
)
|
||||||
|
.where(owner)
|
||||||
.where(Photo.duplicate_group_id.is_not(None))
|
.where(Photo.duplicate_group_id.is_not(None))
|
||||||
.where(Photo.is_discarded.is_(False))
|
.where(Photo.is_discarded.is_(False))
|
||||||
.where(Photo.is_hidden.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")
|
@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.
|
"""Recompute duplicate groups from current perceptual hashes.
|
||||||
|
|
||||||
Fires the celery `regroup_duplicates` task which walks every photo's
|
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")
|
@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.
|
"""Compute perceptual hashes for every photo currently missing one.
|
||||||
|
|
||||||
One-shot recovery path for libraries that existed before the phash
|
One-shot recovery path for libraries that existed before the phash
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -19,11 +20,13 @@ logger = logging.getLogger(__name__)
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo, Folder, Tag
|
from app.models import Photo, Folder, Tag
|
||||||
from app.models.folders import SourceRoot
|
from app.models.folders import SourceRoot
|
||||||
|
from app.models.user import User
|
||||||
from app.models.heaps import heap_photos
|
from app.models.heaps import heap_photos
|
||||||
from app.models.tags import photo_tags
|
from app.models.tags import photo_tags
|
||||||
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
from app.schemas.photos import PhotoResponse, PhotoUpdate, PhotoListResponse, BulkAction
|
||||||
from app.services.exif_writer import ExifWriteError, write_taken_at
|
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.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
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -47,13 +50,15 @@ async def list_photos(
|
|||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(100, ge=1, le=500),
|
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"""
|
"""List photos with filters and pagination"""
|
||||||
|
|
||||||
# Build query — eager-load tags so the response can include them
|
# Build query — eager-load tags so the response can include them
|
||||||
# without an N+1 round-trip per photo.
|
# without an N+1 round-trip per photo. Scoped to the current user.
|
||||||
query = select(Photo).options(selectinload(Photo.tags))
|
query = select(Photo).options(selectinload(Photo.tags)).where(Photo.user_id == current_user.id)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
filters = []
|
filters = []
|
||||||
@@ -206,24 +211,92 @@ async def list_photos(
|
|||||||
"rating": Photo.rating,
|
"rating": Photo.rating,
|
||||||
}
|
}
|
||||||
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
|
sort_column = SORT_WHITELIST.get(sort, Photo.taken_at)
|
||||||
if order == "desc":
|
desc = order == "desc"
|
||||||
query = query.order_by(sort_column.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:
|
else:
|
||||||
query = query.order_by(sort_column.asc())
|
query = query.order_by(sort_column.asc().nulls_last(), Photo.id.asc())
|
||||||
|
|
||||||
# Count total results
|
# Count total results (only when no cursor — first page needs it;
|
||||||
count_query = select(func.count()).select_from(query.subquery())
|
# subsequent pages reuse the total from the first response).
|
||||||
total_result = await db.execute(count_query)
|
total = None
|
||||||
total = total_result.scalar()
|
if not cursor:
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
# Apply pagination
|
total_result = await db.execute(count_query)
|
||||||
offset = (page - 1) * per_page
|
total = total_result.scalar()
|
||||||
query = query.offset(offset).limit(per_page)
|
|
||||||
|
# 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
|
# Execute query
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
photos = result.scalars().all()
|
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
|
# Convert to response, attaching tags inline so the frontend can group
|
||||||
# client-side without a second round-trip.
|
# client-side without a second round-trip.
|
||||||
photo_dicts = []
|
photo_dicts = []
|
||||||
@@ -235,21 +308,25 @@ async def list_photos(
|
|||||||
]
|
]
|
||||||
photo_dicts.append(d)
|
photo_dicts.append(d)
|
||||||
|
|
||||||
return {
|
response = {
|
||||||
"photos": photo_dicts,
|
"photos": photo_dicts,
|
||||||
"total": total,
|
|
||||||
"page": page,
|
|
||||||
"per_page": per_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")
|
@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
|
"""Lightweight listing of every non-discarded photo that has GPS
|
||||||
coordinates, used by the Map view. Intentionally returns a flat list
|
coordinates, used by the Map view."""
|
||||||
(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."""
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(
|
select(
|
||||||
Photo.id,
|
Photo.id,
|
||||||
@@ -257,6 +334,7 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
|
|||||||
Photo.longitude,
|
Photo.longitude,
|
||||||
Photo.taken_at,
|
Photo.taken_at,
|
||||||
).where(
|
).where(
|
||||||
|
Photo.user_id == current_user.id,
|
||||||
Photo.is_discarded.is_(False),
|
Photo.is_discarded.is_(False),
|
||||||
Photo.is_hidden.is_(False),
|
Photo.is_hidden.is_(False),
|
||||||
Photo.latitude.is_not(None),
|
Photo.latitude.is_not(None),
|
||||||
@@ -277,16 +355,11 @@ async def list_photos_with_gps(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/{photo_id}")
|
@router.get("/{photo_id}")
|
||||||
async def get_photo(
|
async def get_photo(
|
||||||
photo_id: str,
|
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."""
|
"""Get single photo with full EXIF and its tags."""
|
||||||
result = await db.execute(
|
photo = await get_user_photo(photo_id, current_user, db)
|
||||||
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")
|
|
||||||
|
|
||||||
# Fetch tags via the join table so we don't need to declare a
|
# Fetch tags via the join table so we don't need to declare a
|
||||||
# relationship on the Photo model side.
|
# relationship on the Photo model side.
|
||||||
@@ -310,12 +383,11 @@ async def add_photo_tags(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
body: dict,
|
body: dict,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
|
"""Add one or more tags to a photo. Body: { tag_ids: [str, ...] }.
|
||||||
Idempotent: re-adding existing members is a no-op."""
|
Idempotent: re-adding existing members is a no-op."""
|
||||||
photo_result = await db.execute(select(Photo).where(Photo.id == photo_id))
|
await get_user_photo(photo_id, current_user, db)
|
||||||
if photo_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
|
||||||
|
|
||||||
tag_ids = body.get("tag_ids") or []
|
tag_ids = body.get("tag_ids") or []
|
||||||
if not isinstance(tag_ids, list) or not tag_ids:
|
if not isinstance(tag_ids, list) or not tag_ids:
|
||||||
@@ -346,8 +418,10 @@ async def remove_photo_tag(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
tag_id: str,
|
tag_id: str,
|
||||||
db: AsyncSession = Depends(get_db),
|
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."""
|
"""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
|
from sqlalchemy import delete as sql_delete
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql_delete(photo_tags).where(
|
sql_delete(photo_tags).where(
|
||||||
@@ -363,22 +437,21 @@ async def get_thumbnail(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
size: str,
|
size: str,
|
||||||
response: Response,
|
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)"""
|
"""Serve thumbnail (with Nginx X-Accel-Redirect support)"""
|
||||||
if size not in ['small', 'medium', 'large']:
|
if size not in ['small', 'medium', 'large']:
|
||||||
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
|
raise HTTPException(status_code=400, detail="Invalid thumbnail size")
|
||||||
|
|
||||||
result = await db.execute(
|
photo = await get_user_photo(photo_id, current_user, db)
|
||||||
select(Photo).where(Photo.id == photo_id)
|
|
||||||
)
|
# Check if thumbnail exists, generate if not.
|
||||||
photo = result.scalar_one_or_none()
|
# User-prefixed path for isolation.
|
||||||
|
if photo.user_id:
|
||||||
if not photo:
|
thumb_dir = f"/data/thumbs/{photo.user_id}/{photo_id}"
|
||||||
raise HTTPException(status_code=404, detail="Photo not found")
|
else:
|
||||||
|
thumb_dir = f"/data/thumbs/{photo_id}"
|
||||||
# Check if thumbnail exists, generate if not
|
|
||||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
|
||||||
thumb_path = f"{thumb_dir}/{size}.webp"
|
thumb_path = f"{thumb_dir}/{size}.webp"
|
||||||
|
|
||||||
if not os.path.exists(thumb_path):
|
if not os.path.exists(thumb_path):
|
||||||
@@ -451,16 +524,11 @@ async def get_thumbnail(
|
|||||||
@router.get("/{photo_id}/original")
|
@router.get("/{photo_id}/original")
|
||||||
async def get_original(
|
async def get_original(
|
||||||
photo_id: str,
|
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)"""
|
"""Serve original file (download for RAW, inline for web-safe formats)"""
|
||||||
result = await db.execute(
|
photo = await get_user_photo(photo_id, current_user, db)
|
||||||
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")
|
|
||||||
|
|
||||||
if not os.path.exists(photo.filepath):
|
if not os.path.exists(photo.filepath):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
@@ -560,19 +628,10 @@ async def get_proxy(
|
|||||||
photo_id: str,
|
photo_id: str,
|
||||||
response: Response,
|
response: Response,
|
||||||
db: AsyncSession = Depends(get_db),
|
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,
|
"""Serve a full-resolution WebP proxy for non-web-safe formats."""
|
||||||
TIFF) so the loupe view can display them inline. Web-safe formats are
|
photo = await get_user_photo(photo_id, current_user, db)
|
||||||
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")
|
|
||||||
|
|
||||||
if not os.path.exists(photo.filepath):
|
if not os.path.exists(photo.filepath):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
@@ -613,19 +672,11 @@ async def get_proxy(
|
|||||||
async def update_photo(
|
async def update_photo(
|
||||||
photo_id: str,
|
photo_id: str,
|
||||||
update: PhotoUpdate,
|
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
|
"""Update photo metadata."""
|
||||||
file on disk in its current directory (no cross-folder moves through
|
photo = await get_user_photo(photo_id, current_user, db)
|
||||||
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_data = update.dict(exclude_unset=True)
|
update_data = update.dict(exclude_unset=True)
|
||||||
|
|
||||||
@@ -692,19 +743,11 @@ async def update_photo(
|
|||||||
@router.delete("/{photo_id}")
|
@router.delete("/{photo_id}")
|
||||||
async def discard_photo(
|
async def discard_photo(
|
||||||
photo_id: str,
|
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
|
"""Soft-discard a photo."""
|
||||||
restore is just a flag flip. Permanent deletion happens via DELETE
|
photo = await get_user_photo(photo_id, current_user, db)
|
||||||
/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")
|
|
||||||
|
|
||||||
photo.is_discarded = True
|
photo.is_discarded = True
|
||||||
photo.discarded_at = datetime.utcnow()
|
photo.discarded_at = datetime.utcnow()
|
||||||
@@ -726,6 +769,7 @@ class CopyRequest(BaseModel):
|
|||||||
async def copy_photos(
|
async def copy_photos(
|
||||||
body: CopyRequest,
|
body: CopyRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Copy photos into a target folder. Same target resolution as /move
|
"""Copy photos into a target folder. Same target resolution as /move
|
||||||
(folder id or source root id), but uses shutil.copy2 and creates new
|
(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": []}
|
return {"status": "success", "copied": 0, "errors": []}
|
||||||
|
|
||||||
photos_result = await db.execute(
|
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()
|
photos_to_copy = photos_result.scalars().all()
|
||||||
|
|
||||||
@@ -820,6 +864,7 @@ async def copy_photos(
|
|||||||
file_size=photo.file_size,
|
file_size=photo.file_size,
|
||||||
taken_at=photo.taken_at,
|
taken_at=photo.taken_at,
|
||||||
taken_at_source=photo.taken_at_source,
|
taken_at_source=photo.taken_at_source,
|
||||||
|
user_id=current_user.id,
|
||||||
user_title=photo.user_title,
|
user_title=photo.user_title,
|
||||||
user_notes=photo.user_notes,
|
user_notes=photo.user_notes,
|
||||||
rating=photo.rating,
|
rating=photo.rating,
|
||||||
@@ -844,6 +889,7 @@ async def copy_photos(
|
|||||||
async def move_photos(
|
async def move_photos(
|
||||||
body: MoveRequest,
|
body: MoveRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
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
|
"""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
|
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:
|
if not body.photo_ids:
|
||||||
return {"status": "success", "moved": 0, "errors": []}
|
return {"status": "success", "moved": 0, "errors": []}
|
||||||
|
|
||||||
# Fetch the photo rows
|
# Fetch the photo rows, scoped to user
|
||||||
photos_result = await db.execute(
|
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()
|
photos_to_move = photos_result.scalars().all()
|
||||||
|
|
||||||
@@ -932,12 +978,13 @@ async def move_photos(
|
|||||||
@router.post("/bulk")
|
@router.post("/bulk")
|
||||||
async def bulk_action(
|
async def bulk_action(
|
||||||
action: BulkAction,
|
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"""
|
"""Perform bulk actions on multiple photos"""
|
||||||
# Get photos
|
# Get photos, scoped to user
|
||||||
result = await db.execute(
|
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()
|
photos = result.scalars().all()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo
|
from app.models import Photo
|
||||||
from app.services.search import hybrid_search
|
from app.services.search import hybrid_search
|
||||||
|
from app.models.user import User
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ class SearchRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@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)
|
"""Unified search endpoint. Every query runs hybrid (FTS + semantic)
|
||||||
by default — the user never picks a mode.
|
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
|
# Hydrate with photo data
|
||||||
photo_ids = [r["photo_id"] for r in results]
|
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()
|
rows = (await db.execute(stmt)).scalars().all()
|
||||||
photo_map = {p.id: p for p in rows}
|
photo_map = {p.id: p for p in rows}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Photo, Tag
|
from app.models import Photo, Tag
|
||||||
from app.models.tags import photo_tags
|
from app.models.tags import photo_tags
|
||||||
|
from app.models.user import User
|
||||||
|
from app.dependencies import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ class TagMerge(BaseModel):
|
|||||||
async def list_tags(
|
async def list_tags(
|
||||||
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
|
kind: Optional[str] = Query(None, description="Filter by kind: user, object, scene, face_cluster"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""List all tags with their photo counts, optionally filtered by kind.
|
"""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)
|
photo_tags.join(Photo, Photo.id == photo_tags.c.photo_id)
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
|
Photo.user_id == current_user.id,
|
||||||
Photo.is_discarded.is_(False),
|
Photo.is_discarded.is_(False),
|
||||||
Photo.is_hidden.is_(False),
|
Photo.is_hidden.is_(False),
|
||||||
)
|
)
|
||||||
@@ -70,6 +74,7 @@ async def list_tags(
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id)
|
||||||
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
.outerjoin(count_subq, Tag.id == count_subq.c.tag_id)
|
||||||
|
.where(Tag.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
if kind:
|
if kind:
|
||||||
stmt = stmt.where(Tag.kind == kind)
|
stmt = stmt.where(Tag.kind == kind)
|
||||||
@@ -93,7 +98,7 @@ async def list_tags(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@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
|
"""Create a new tag. The (name, kind) pair is unique — re-creating an
|
||||||
existing pair returns the existing row (idempotent for autocomplete)."""
|
existing pair returns the existing row (idempotent for autocomplete)."""
|
||||||
name = (body.name or "").strip()
|
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")
|
raise HTTPException(status_code=400, detail="Tag name is required")
|
||||||
|
|
||||||
existing = await db.execute(
|
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()
|
found = existing.scalar_one_or_none()
|
||||||
if found:
|
if found:
|
||||||
@@ -110,7 +115,7 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
"kind": found.kind, "photo_count": 0,
|
"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)
|
db.add(tag)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tag)
|
await db.refresh(tag)
|
||||||
@@ -122,10 +127,11 @@ async def create_tag(body: TagCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.patch("/{tag_id}")
|
@router.patch("/{tag_id}")
|
||||||
async def update_tag(
|
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)."""
|
"""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()
|
tag = result.scalar_one_or_none()
|
||||||
if not tag:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
@@ -145,7 +151,8 @@ async def update_tag(
|
|||||||
|
|
||||||
@router.post("/{tag_id}/merge")
|
@router.post("/{tag_id}/merge")
|
||||||
async def merge_tag(
|
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
|
"""Merge tag_id INTO target_id. All photo associations from the source
|
||||||
tag are moved to the target, then the source tag is deleted.
|
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:
|
if tag_id == body.target_id:
|
||||||
raise HTTPException(status_code=400, detail="Cannot merge a tag into itself")
|
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()
|
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))).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:
|
if not source:
|
||||||
raise HTTPException(status_code=404, detail="Source tag not found")
|
raise HTTPException(status_code=404, detail="Source tag not found")
|
||||||
if not target:
|
if not target:
|
||||||
@@ -189,9 +196,9 @@ async def merge_tag(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{tag_id}", status_code=204)
|
@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."""
|
"""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()
|
tag = result.scalar_one_or_none()
|
||||||
if not tag:
|
if not tag:
|
||||||
raise HTTPException(status_code=404, detail="Tag not found")
|
raise HTTPException(status_code=404, detail="Tag not found")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Scanner service for initial library scan and one-time bootstrap of the
|
Scanner service for initial library scan and per-user source root bootstrap.
|
||||||
default source root on first boot.
|
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -8,44 +7,74 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models import SourceRoot
|
from app.models import SourceRoot
|
||||||
|
from app.models.user import User
|
||||||
from app.tasks.scan import scan_all_source_roots
|
from app.tasks.scan import scan_all_source_roots
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# The single host → container mount path. The compose file mounts whatever
|
|
||||||
# the user set as PHOTO_DIRS at this path.
|
async def bootstrap_user_source_root(user: User, session=None) -> None:
|
||||||
DEFAULT_LIBRARY_PATH = "/photos"
|
"""Create the media directory and a source root for a user.
|
||||||
DEFAULT_LIBRARY_NAME = "Library"
|
|
||||||
|
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:
|
async def bootstrap_default_source_root() -> None:
|
||||||
"""If no source roots exist in the DB, create one pointing at the default
|
"""Legacy bootstrap — for existing installs that have source roots
|
||||||
library mount. Lets a fresh install pick up photos with zero
|
without user_id (pre-auth migration). On fresh installs, source roots
|
||||||
configuration: the user only needs to set PHOTO_DIRS in .env.
|
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:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(select(SourceRoot))
|
result = await session.execute(select(SourceRoot))
|
||||||
if result.scalars().first() is not None:
|
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(
|
# No source roots and no users means fresh install — the setup
|
||||||
name=DEFAULT_LIBRARY_NAME,
|
# endpoint will create the first user + source root.
|
||||||
path=DEFAULT_LIBRARY_PATH,
|
user_count = (await session.execute(
|
||||||
)
|
select(User)
|
||||||
session.add(source_root)
|
)).scalars().first()
|
||||||
await session.commit()
|
if user_count is None:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Bootstrapped default source root: {DEFAULT_LIBRARY_NAME} → "
|
"No users or source roots — waiting for first-run setup."
|
||||||
f"{DEFAULT_LIBRARY_PATH}"
|
)
|
||||||
)
|
return
|
||||||
|
|
||||||
|
|
||||||
async def start_initial_scan():
|
async def start_initial_scan():
|
||||||
|
|||||||
@@ -122,6 +122,13 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
|||||||
if not source_root_id:
|
if not source_root_id:
|
||||||
source_root = await get_or_create_source_root(session, folder_path)
|
source_root = await get_or_create_source_root(session, folder_path)
|
||||||
source_root_id = source_root.id
|
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
|
# Per-scan memoization cache for "is this folder's effective
|
||||||
# is_hidden true?" Populated on first lookup by walking the
|
# 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):
|
for root, dirs, files in os.walk(folder_path):
|
||||||
# Get or create folder entry
|
# 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)
|
progress_set(REDIS_KEY_CURRENT_FOLDER, root)
|
||||||
|
|
||||||
# Filter supported files
|
# Filter supported files
|
||||||
@@ -237,6 +244,7 @@ async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], ta
|
|||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
folder_id=folder.id,
|
folder_id=folder.id,
|
||||||
|
user_id=owner_user_id,
|
||||||
file_hash=file_hash,
|
file_hash=file_hash,
|
||||||
media_type=get_media_type(filepath),
|
media_type=get_media_type(filepath),
|
||||||
original_format=Path(filepath).suffix.upper()[1:],
|
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
|
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."""
|
"""Get or create a folder entry, matching by normalized path."""
|
||||||
from sqlalchemy import select
|
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
|
parent_id = parent.id
|
||||||
else:
|
else:
|
||||||
# Recursively create parent
|
# 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
|
parent_id = parent.id
|
||||||
else:
|
else:
|
||||||
parent_id = None
|
parent_id = None
|
||||||
@@ -374,6 +384,7 @@ async def get_or_create_folder(session: AsyncSession, path: str, source_root_id:
|
|||||||
path=norm,
|
path=norm,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
source_root_id=source_root_id,
|
source_root_id=source_root_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
session.add(folder)
|
session.add(folder)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ THUMB_SIZES = {
|
|||||||
'large': settings.thumbnails.large
|
'large': settings.thumbnails.large
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_thumb_path(photo_id: str, size: str) -> str:
|
def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str:
|
||||||
"""Get the path for a thumbnail file"""
|
"""Get the path for a thumbnail file.
|
||||||
thumb_dir = f"/data/thumbs/{photo_id}"
|
|
||||||
|
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)
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
return f"{thumb_dir}/{size}.{settings.thumbnails.format}"
|
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
|
# Generate thumbnails for each size
|
||||||
for size_name, size_value in THUMB_SIZES.items():
|
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)
|
generate_thumbnail(image, size_value, thumb_path)
|
||||||
|
|
||||||
# Update database with thumbnail path
|
# Update database with thumbnail path
|
||||||
|
|||||||
54
backend/bootstrap.py
Normal file
54
backend/bootstrap.py
Normal 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()
|
||||||
@@ -54,6 +54,7 @@ aiofiles==23.2.1
|
|||||||
# Security and authentication
|
# Security and authentication
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
pytest==7.4.4
|
pytest==7.4.4
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ services:
|
|||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
- 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}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ services:
|
|||||||
# Run Alembic migrations before starting uvicorn. On a fresh Postgres
|
# 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
|
# the empty 0001 baseline is a no-op stamp; create_all in init_db then
|
||||||
# builds the schema.
|
# 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:
|
environment:
|
||||||
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
- DATABASE_URL=postgresql+asyncpg://mulita:mulita@db:5432/mulita
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
@@ -46,6 +50,9 @@ services:
|
|||||||
- CELERY_RESULT_BACKEND=redis://redis:6379
|
- CELERY_RESULT_BACKEND=redis://redis:6379
|
||||||
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
- PHOTO_DIRS=${PHOTO_DIRS:-/photos}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
|
- 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}
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -15,17 +15,19 @@ import { KeyboardHints } from './components/KeyboardHints'
|
|||||||
import { PreviewView } from './components/preview/PreviewView'
|
import { PreviewView } from './components/preview/PreviewView'
|
||||||
import { FilterBar } from './components/filter/FilterBar'
|
import { FilterBar } from './components/filter/FilterBar'
|
||||||
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
import { DiscardActionBar } from './components/discard/DiscardActionBar'
|
||||||
import { SettingsDialog } from './components/dialogs/SettingsDialog'
|
import { SettingsPage } from './components/dialogs/SettingsDialog'
|
||||||
import { usePhotoStore } from './store/photoStore'
|
import { usePhotoStore } from './store/photoStore'
|
||||||
import { useFilterStore } from './store/filterStore'
|
import { useFilterStore } from './store/filterStore'
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||||
import { usePhotosQuery } from './hooks/usePhotosQuery'
|
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 [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
const currentSection = useFilterStore((s) => s.currentSection)
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
|
|
||||||
@@ -44,13 +46,13 @@ function App() {
|
|||||||
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
|
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
|
// Right sidebar stays open by default and shows whatever's selected
|
||||||
// (or an empty state if nothing is). User can still toggle it manually.
|
// (or an empty state if nothing is). User can still toggle it manually.
|
||||||
// Note: deliberately NOT gated on viewMode — the preview overlay sits
|
const showRightSidebar = rightSidebarOpen && !isSettings
|
||||||
// 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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
@@ -70,7 +72,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
onCollapse={() => setLeftSidebarOpen(false)}
|
onCollapse={() => setLeftSidebarOpen(false)}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,14 +80,12 @@ function App() {
|
|||||||
* across the sidebar. relative so the KeyboardHints overlay
|
* across the sidebar. relative so the KeyboardHints overlay
|
||||||
* centers against this column, not the viewport. */}
|
* centers against this column, not the viewport. */}
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||||
<FilterBar />
|
{!isSettings && <FilterBar />}
|
||||||
<DiscardActionBar />
|
{!isSettings && <DiscardActionBar />}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{/* Section-level routing. The Map view replaces the timeline
|
{currentSection === 'settings' ? (
|
||||||
* with a Leaflet map of GPS-tagged photos; Duplicates gets its
|
<SettingsPage />
|
||||||
* own grouped grid; everything else falls through to the
|
) : currentSection === 'map' ? (
|
||||||
* filter-driven Timeline. */}
|
|
||||||
{currentSection === 'map' ? (
|
|
||||||
<MapView />
|
<MapView />
|
||||||
) : currentSection === 'duplicates' ? (
|
) : currentSection === 'duplicates' ? (
|
||||||
<DuplicatesView />
|
<DuplicatesView />
|
||||||
@@ -102,10 +101,7 @@ function App() {
|
|||||||
<Timeline />
|
<Timeline />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Floating keyboard hints — bottom-center of the main column,
|
{!isSettings && <KeyboardHints />}
|
||||||
* glassy. Mounted here so it's centered against the timeline,
|
|
||||||
* not the viewport (which would be offset by the sidebars). */}
|
|
||||||
<KeyboardHints />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar */}
|
{/* Right Sidebar */}
|
||||||
@@ -127,13 +123,33 @@ function App() {
|
|||||||
{/* Preview overlay — covers TopBar when active */}
|
{/* Preview overlay — covers TopBar when active */}
|
||||||
{viewMode === 'preview' && <PreviewView />}
|
{viewMode === 'preview' && <PreviewView />}
|
||||||
|
|
||||||
{/* Settings panel — admin/maintenance actions */}
|
|
||||||
<SettingsDialog
|
|
||||||
isOpen={settingsOpen}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</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…</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup) return <SetupPage />
|
||||||
|
if (!user) return <LoginPage />
|
||||||
|
return <MainApp />
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
336
frontend/src/components/admin/UserManagement.tsx
Normal file
336
frontend/src/components/admin/UserManagement.tsx
Normal 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…</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
frontend/src/components/auth/LoginPage.tsx
Normal file
81
frontend/src/components/auth/LoginPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/components/auth/SetupPage.tsx
Normal file
119
frontend/src/components/auth/SetupPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
X,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Wrench,
|
Wrench,
|
||||||
Film,
|
Film,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Activity,
|
Activity,
|
||||||
FolderSearch,
|
FolderSearch,
|
||||||
|
Shield,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
type WorkerStatus,
|
type WorkerStatus,
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { toast } from '../ToastContainer'
|
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
|
// 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
|
// 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.
|
// the grid renders from. Imported via the canonical hook key.
|
||||||
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
|
import { DUPLICATE_GROUPS_QUERY_KEY } from '../../hooks/useDuplicateGroupsQuery'
|
||||||
|
|
||||||
interface SettingsDialogProps {
|
type SettingsTab = 'library' | 'users'
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
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
|
* Full-page settings view with tabbed navigation. Replaces the old
|
||||||
* endpoints exposed by /api/v1/library/maintenance/* — regenerate
|
* modal dialog — renders as a top-level section in the main content
|
||||||
* thumbnails (with filters), run the data-integrity cleanup, and trigger
|
* area (like Timeline or MapView).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
export function SettingsPage() {
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('library')
|
||||||
const [showAllErrors, setShowAllErrors] = useState(false)
|
const [showAllErrors, setShowAllErrors] = useState(false)
|
||||||
// One key per action so each button has its own spinner without
|
// One key per action so each button has its own spinner without
|
||||||
// blocking the others.
|
// blocking the others.
|
||||||
@@ -65,67 +65,45 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
|
|
||||||
// All four panels fetch through React Query so cached data shows
|
// All four panels fetch through React Query so cached data shows
|
||||||
// instantly on reopen while a background refetch updates the numbers.
|
// instantly on reopen while a background refetch updates the numbers.
|
||||||
// `enabled: isOpen` avoids firing requests while the dialog is closed,
|
// All queries use scope=global so the admin sees cross-user totals.
|
||||||
// but the cache entries survive between opens (default gcTime = 5m).
|
|
||||||
const thumbStatsQuery = useQuery({
|
const thumbStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_THUMB_STATS_KEY,
|
queryKey: SETTINGS_THUMB_STATS_KEY,
|
||||||
queryFn: library.maintenance.thumbnailStats,
|
queryFn: () => library.maintenance.thumbnailStats('global'),
|
||||||
enabled: isOpen,
|
|
||||||
// Treat as stale immediately so reopening the dialog triggers a
|
|
||||||
// background refetch on top of the cached view.
|
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
const libStatsQuery = useQuery({
|
const libStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_LIB_STATS_KEY,
|
queryKey: SETTINGS_LIB_STATS_KEY,
|
||||||
queryFn: library.stats,
|
queryFn: () => library.stats('global'),
|
||||||
enabled: isOpen,
|
|
||||||
staleTime: 0,
|
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({
|
const workerStatusQuery = useQuery({
|
||||||
queryKey: SETTINGS_WORKER_STATUS_KEY,
|
queryKey: SETTINGS_WORKER_STATUS_KEY,
|
||||||
queryFn: library.maintenance.workerStatus,
|
queryFn: () => library.maintenance.workerStatus('global'),
|
||||||
enabled: isOpen,
|
refetchInterval: 5000,
|
||||||
refetchInterval: isOpen ? 5000 : false,
|
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
const missingStatsQuery = useQuery({
|
const missingStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_MISSING_STATS_KEY,
|
queryKey: SETTINGS_MISSING_STATS_KEY,
|
||||||
queryFn: library.maintenance.missingStats,
|
queryFn: library.maintenance.missingStats,
|
||||||
enabled: isOpen,
|
refetchInterval: 5000,
|
||||||
refetchInterval: isOpen ? 5000 : false,
|
|
||||||
staleTime: 0,
|
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({
|
const pipelineStatsQuery = useQuery({
|
||||||
queryKey: SETTINGS_PIPELINE_STATS_KEY,
|
queryKey: SETTINGS_PIPELINE_STATS_KEY,
|
||||||
queryFn: library.maintenance.pipelineStats,
|
queryFn: () => library.maintenance.pipelineStats('global'),
|
||||||
enabled: isOpen,
|
refetchInterval: 5000,
|
||||||
refetchInterval: isOpen ? 5000 : false,
|
|
||||||
staleTime: 0,
|
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>({
|
const scanStatusQuery = useQuery<ScanStatus>({
|
||||||
queryKey: SETTINGS_SCAN_STATUS_KEY,
|
queryKey: SETTINGS_SCAN_STATUS_KEY,
|
||||||
queryFn: library.scanStatus,
|
queryFn: library.scanStatus,
|
||||||
enabled: isOpen,
|
|
||||||
refetchInterval: (q) =>
|
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,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
// Duplicates: shares the cache with DuplicatesView so a regroup
|
|
||||||
// triggered from Settings updates the grid view immediately.
|
|
||||||
const duplicatesQuery = useQuery({
|
const duplicatesQuery = useQuery({
|
||||||
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
queryKey: [...DUPLICATE_GROUPS_QUERY_KEY, 'global'],
|
||||||
queryFn: library.duplicates.groups,
|
queryFn: () => library.duplicates.groups('global'),
|
||||||
enabled: isOpen,
|
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,29 +148,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
// Surface fetch errors once (React Query de-dupes retries but we still
|
// Surface fetch errors once (React Query de-dupes retries but we still
|
||||||
// want a single toast so the user knows something went wrong).
|
// want a single toast so the user knows something went wrong).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
|
||||||
if (thumbStatsQuery.error || libStatsQuery.error) {
|
if (thumbStatsQuery.error || libStatsQuery.error) {
|
||||||
console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error)
|
console.error('Failed to load settings stats', thumbStatsQuery.error ?? libStatsQuery.error)
|
||||||
toast.error('Could not load library stats')
|
toast.error('Could not load library stats')
|
||||||
}
|
}
|
||||||
}, [isOpen, thumbStatsQuery.error, libStatsQuery.error])
|
}, [thumbStatsQuery.error, libStatsQuery.error])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
|
||||||
if (workerStatusQuery.error || missingStatsQuery.error) {
|
if (workerStatusQuery.error || missingStatsQuery.error) {
|
||||||
console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error)
|
console.error('Failed to load worker status', workerStatusQuery.error ?? missingStatsQuery.error)
|
||||||
toast.error('Could not load worker status')
|
toast.error('Could not load worker status')
|
||||||
}
|
}
|
||||||
}, [isOpen, workerStatusQuery.error, missingStatsQuery.error])
|
}, [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])
|
|
||||||
|
|
||||||
const runAction = useCallback(
|
const runAction = useCallback(
|
||||||
async <T,>(
|
async <T,>(
|
||||||
@@ -229,39 +195,40 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
) =>
|
) =>
|
||||||
runAction(
|
runAction(
|
||||||
key,
|
key,
|
||||||
() => library.maintenance.regenerateThumbnails(body),
|
() => library.maintenance.regenerateThumbnails(body, 'global'),
|
||||||
'Regeneration queued',
|
'Regeneration queued',
|
||||||
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
|
(r) => `${r.queued} photos queued, ${r.cleared_dirs} thumb dirs cleared`
|
||||||
),
|
),
|
||||||
[runAction]
|
[runAction]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isOpen) return null
|
const visibleTabs = TABS.filter((t) => !t.adminOnly || isAdmin)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[2000]">
|
<div className="flex h-full flex-col">
|
||||||
<div
|
{/* Tab bar */}
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
<div className="flex items-center gap-1 border-b border-border bg-surface px-4 py-1.5">
|
||||||
onClick={onClose}
|
{visibleTabs.map((tab) => (
|
||||||
/>
|
<button
|
||||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
key={tab.id}
|
||||||
<div className="relative z-10 flex max-h-[85vh] w-[640px] flex-col rounded-lg border border-border bg-surface shadow-2xl">
|
onClick={() => setActiveTab(tab.id)}
|
||||||
{/* Header */}
|
className={clsx(
|
||||||
<div className="flex items-center justify-between border-b border-border px-5 py-3">
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
<h2 className="text-base font-semibold text-text">Settings</h2>
|
activeTab === tab.id
|
||||||
<button
|
? 'bg-primary/20 text-primary'
|
||||||
onClick={onClose}
|
: 'text-text-muted hover:bg-surface-2 hover:text-text',
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
)}
|
||||||
title="Close (Esc)"
|
>
|
||||||
>
|
{tab.label}
|
||||||
<X className="h-4 w-4" />
|
</button>
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
{/* Tab content */}
|
||||||
{/* ----------------------------------------------------- */}
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
{/* Library overview */}
|
<div className="mx-auto max-w-2xl">
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
|
{activeTab === 'library' && (<>
|
||||||
<Section
|
<Section
|
||||||
icon={<Database className="h-4 w-4" />}
|
icon={<Database className="h-4 w-4" />}
|
||||||
title="Library"
|
title="Library"
|
||||||
@@ -334,9 +301,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
{/* Pipeline progress — per-stage done/total */}
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
<Section
|
<Section
|
||||||
icon={<Activity className="h-4 w-4" />}
|
icon={<Activity className="h-4 w-4" />}
|
||||||
title="Pipeline progress"
|
title="Pipeline progress"
|
||||||
@@ -379,9 +343,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
{/* Duplicate detection */}
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
<Section
|
<Section
|
||||||
icon={<Copy className="h-4 w-4" />}
|
icon={<Copy className="h-4 w-4" />}
|
||||||
title="Duplicates"
|
title="Duplicates"
|
||||||
@@ -433,9 +394,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
{/* Thumbnail maintenance */}
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
<Section
|
<Section
|
||||||
icon={<ImageIcon className="h-4 w-4" />}
|
icon={<ImageIcon className="h-4 w-4" />}
|
||||||
title="Thumbnails"
|
title="Thumbnails"
|
||||||
@@ -524,9 +483,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
{/* Worker fleet diagnostics */}
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
<Section
|
<Section
|
||||||
icon={<Cpu className="h-4 w-4" />}
|
icon={<Cpu className="h-4 w-4" />}
|
||||||
title="Workers"
|
title="Workers"
|
||||||
@@ -848,9 +804,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
{/* Data integrity */}
|
|
||||||
{/* ----------------------------------------------------- */}
|
|
||||||
<Section
|
<Section
|
||||||
icon={<Wrench className="h-4 w-4" />}
|
icon={<Wrench className="h-4 w-4" />}
|
||||||
title="Maintenance"
|
title="Maintenance"
|
||||||
@@ -876,7 +829,17 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</>)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && isAdmin && (
|
||||||
|
<Section
|
||||||
|
icon={<Shield className="h-4 w-4" />}
|
||||||
|
title="User Management"
|
||||||
|
>
|
||||||
|
<UserManagement />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
User as UserIcon,
|
||||||
|
LogOut,
|
||||||
|
Shield,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||||
@@ -40,6 +43,7 @@ import {
|
|||||||
import { registerUndoable } from '../../store/undoStore'
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -55,10 +59,10 @@ interface TreeItem {
|
|||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
onCollapse: () => void
|
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']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
// Inline rename state for source-root rows. Stores the id being edited
|
// Inline rename state for source-root rows. Stores the id being edited
|
||||||
// and the draft name. Double-click a folder row to start.
|
// and the draft name. Double-click a folder row to start.
|
||||||
@@ -797,17 +801,40 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
<HeapsPanel />
|
<HeapsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings entry point — pinned to the bottom of the panel so it
|
{/* Bottom panel — user identity + settings, pinned below the tree. */}
|
||||||
* sits out of the way of the library tree but is always reachable. */}
|
<div className="border-t border-border p-1.5 space-y-0.5">
|
||||||
<div className="border-t border-border p-1.5">
|
{/* User row */}
|
||||||
<button
|
<div className="flex items-center gap-2 rounded px-2 py-1 text-[12px] text-text-muted">
|
||||||
onClick={onOpenSettings}
|
<UserIcon className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
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"
|
<span className="flex-1 truncate text-text">{user?.username}</span>
|
||||||
title="Settings"
|
{isAdmin && (
|
||||||
>
|
<span className="rounded bg-accent/20 px-1 py-px text-[10px] leading-none text-accent flex-shrink-0">
|
||||||
<Settings className="h-3.5 w-3.5" />
|
<Shield className="inline h-2.5 w-2.5" />
|
||||||
Settings
|
</span>
|
||||||
</button>
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<DeleteFolderDialog
|
<DeleteFolderDialog
|
||||||
|
|||||||
@@ -96,22 +96,20 @@ export function TopBar({
|
|||||||
{MULIMAGO_ASCII}
|
{MULIMAGO_ASCII}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</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 && (
|
{!rightSidebarOpen && (
|
||||||
<button
|
<button
|
||||||
onClick={onExpandRight}
|
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)"
|
title="Expand panel (I)"
|
||||||
aria-label="Expand right panel"
|
aria-label="Expand right panel"
|
||||||
>
|
>
|
||||||
<PanelRightOpen className="h-4 w-4" />
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span
|
|
||||||
className="text-[10px] font-serif text-black/80"
|
|
||||||
>
|
|
||||||
Built with hubris • {toRoman(new Date().getFullYear())}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
170
frontend/src/contexts/AuthContext.tsx
Normal file
170
frontend/src/contexts/AuthContext.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export const DUPLICATE_GROUPS_QUERY_KEY = ['library', 'duplicates'] as const
|
|||||||
export function useDuplicateGroupsQuery() {
|
export function useDuplicateGroupsQuery() {
|
||||||
return useQuery<DuplicateGroupsResponse>({
|
return useQuery<DuplicateGroupsResponse>({
|
||||||
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
queryKey: DUPLICATE_GROUPS_QUERY_KEY,
|
||||||
queryFn: library.duplicates.groups,
|
queryFn: () => library.duplicates.groups(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const
|
|||||||
export function useLibraryStatsQuery() {
|
export function useLibraryStatsQuery() {
|
||||||
return useQuery<LibraryStats>({
|
return useQuery<LibraryStats>({
|
||||||
queryKey: LIBRARY_STATS_QUERY_KEY,
|
queryKey: LIBRARY_STATS_QUERY_KEY,
|
||||||
queryFn: library.stats,
|
queryFn: () => library.stats(),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
||||||
// .env → bootstrap on backend startup), so the UI only reads them and
|
// .env → bootstrap on backend startup), so the UI only reads them and
|
||||||
// optionally renames the display label.
|
// optionally renames the display label.
|
||||||
@@ -394,15 +449,19 @@ export const library = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
stats: async (): Promise<LibraryStats> => {
|
stats: async (scope?: 'global'): Promise<LibraryStats> => {
|
||||||
const response = await api.get('/library/stats')
|
const response = await api.get('/library/stats', {
|
||||||
|
params: scope ? { scope } : undefined,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Maintenance / admin actions surfaced via the Settings panel. */
|
/** Maintenance / admin actions surfaced via the Settings panel. */
|
||||||
maintenance: {
|
maintenance: {
|
||||||
thumbnailStats: async (): Promise<ThumbnailStats> => {
|
thumbnailStats: async (scope?: 'global'): Promise<ThumbnailStats> => {
|
||||||
const response = await api.get('/library/maintenance/thumbnail-stats')
|
const response = await api.get('/library/maintenance/thumbnail-stats', {
|
||||||
|
params: scope ? { scope } : undefined,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -413,28 +472,30 @@ export const library = {
|
|||||||
media_types?: MediaType[]
|
media_types?: MediaType[]
|
||||||
only_failed?: boolean
|
only_failed?: boolean
|
||||||
only_pending?: boolean
|
only_pending?: boolean
|
||||||
} = {}
|
} = {},
|
||||||
|
scope?: 'global',
|
||||||
): Promise<RegenerateResult> => {
|
): Promise<RegenerateResult> => {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
'/library/maintenance/regenerate-thumbnails',
|
'/library/maintenance/regenerate-thumbnails',
|
||||||
body
|
body,
|
||||||
|
{ params: scope ? { scope } : undefined },
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Celery worker fleet diagnostics + recent task failures. Surfaced
|
/** Celery worker fleet diagnostics + recent task failures. */
|
||||||
* in the Settings panel so users can debug stuck queues without
|
workerStatus: async (scope?: 'global'): Promise<WorkerStatus> => {
|
||||||
* tailing container logs. */
|
const response = await api.get('/library/maintenance/worker-status', {
|
||||||
workerStatus: async (): Promise<WorkerStatus> => {
|
params: scope ? { scope } : undefined,
|
||||||
const response = await api.get('/library/maintenance/worker-status')
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Per-stage ingestion progress — thumbnails, EXIF, GPS, phash,
|
/** Per-stage ingestion progress. */
|
||||||
* embeddings, object tags, OCR, faces, face clusters, duplicate
|
pipelineStats: async (scope?: 'global'): Promise<PipelineStats> => {
|
||||||
* groups. Drives the Pipeline Progress card in Settings. */
|
const response = await api.get('/library/maintenance/pipeline-stats', {
|
||||||
pipelineStats: async (): Promise<PipelineStats> => {
|
params: scope ? { scope } : undefined,
|
||||||
const response = await api.get('/library/maintenance/pipeline-stats')
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -477,8 +538,10 @@ export const library = {
|
|||||||
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
|
/** Duplicate groups computed by app.services.duplicates.regroup_duplicates.
|
||||||
* Drives the grouped grid view in the Duplicates section. */
|
* Drives the grouped grid view in the Duplicates section. */
|
||||||
duplicates: {
|
duplicates: {
|
||||||
groups: async (): Promise<DuplicateGroupsResponse> => {
|
groups: async (scope?: 'global'): Promise<DuplicateGroupsResponse> => {
|
||||||
const response = await api.get('/library/duplicates/groups')
|
const response = await api.get('/library/duplicates/groups', {
|
||||||
|
params: scope ? { scope } : undefined,
|
||||||
|
})
|
||||||
return response.data
|
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
|
export default api
|
||||||
Reference in New Issue
Block a user