Просмотр исходного кода

Merge pull request #219 from maziggy/0.1.7b

Merge fixes
MartinNYHC 3 месяцев назад
Родитель
Сommit
f87ae85e24
51 измененных файлов с 2828 добавлено и 2456 удалено
  1. 55 0
      CHANGELOG.md
  2. 2 1
      README.md
  3. 90 6
      backend/app/api/routes/archives.py
  4. 167 36
      backend/app/api/routes/library.py
  5. 76 6
      backend/app/api/routes/print_queue.py
  6. 26 2
      backend/app/api/routes/printers.py
  7. 97 2040
      backend/app/api/routes/settings.py
  8. 4 0
      backend/app/api/routes/support.py
  9. 64 3
      backend/app/api/routes/users.py
  10. 76 0
      backend/app/core/auth.py
  11. 1 1
      backend/app/core/config.py
  12. 139 3
      backend/app/core/database.py
  13. 41 24
      backend/app/core/permissions.py
  14. 3 0
      backend/app/main.py
  15. 5 0
      backend/app/models/archive.py
  16. 5 0
      backend/app/models/library.py
  17. 5 0
      backend/app/models/print_queue.py
  18. 1 1
      backend/app/models/printer.py
  19. 3 5
      backend/app/models/smart_plug.py
  20. 4 0
      backend/app/schemas/archive.py
  21. 7 0
      backend/app/schemas/library.py
  22. 4 0
      backend/app/schemas/print_queue.py
  23. 19 4
      backend/app/services/archive.py
  24. 102 4
      backend/app/services/bambu_ftp.py
  25. 7 2
      backend/app/services/bambu_mqtt.py
  26. 21 7
      backend/app/services/print_scheduler.py
  27. 14 0
      backend/app/services/printer_manager.py
  28. 133 0
      backend/tests/integration/test_library_api.py
  29. 740 0
      backend/tests/integration/test_ownership_permissions.py
  30. 41 0
      backend/tests/integration/test_printers_api.py
  31. 35 70
      backend/tests/integration/test_settings_api.py
  32. 60 0
      backend/tests/unit/services/test_printer_manager.py
  33. 133 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  34. 96 33
      frontend/src/api/client.ts
  35. 1 1
      frontend/src/components/EmbeddedCameraViewer.tsx
  36. 169 43
      frontend/src/components/GitHubBackupSettings.tsx
  37. 27 0
      frontend/src/contexts/AuthContext.tsx
  38. 99 73
      frontend/src/pages/ArchivesPage.tsx
  39. 1 1
      frontend/src/pages/CameraPage.tsx
  40. 51 44
      frontend/src/pages/FileManagerPage.tsx
  41. 32 8
      frontend/src/pages/PrintersPage.tsx
  42. 25 13
      frontend/src/pages/QueuePage.tsx
  43. 114 11
      frontend/src/pages/SettingsPage.tsx
  44. 2 2
      frontend/src/pages/StatsPage.tsx
  45. 23 9
      frontend/src/pages/StreamOverlayPage.tsx
  46. 0 0
      static/assets/index-1q7Yxq-H.js
  47. 0 0
      static/assets/index-CPqcJWwC.css
  48. 0 0
      static/assets/index-d5ZW47G8.css
  49. 2 2
      static/index.html
  50. 1 1
      test_backend.sh
  51. 5 0
      update_website_wiki.sh

+ 55 - 0
CHANGELOG.md

@@ -2,6 +2,61 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.7b] - Not released
+
+### Enhancements
+- **Ownership-Based Permissions** (Issue #205):
+  - Users can now only update/delete their own items unless they have elevated permissions
+  - Update/delete permissions split into `*_own` and `*_all` variants:
+    - `queue:update_own` / `queue:update_all`
+    - `queue:delete_own` / `queue:delete_all`
+    - `archives:update_own` / `archives:update_all`
+    - `archives:delete_own` / `archives:delete_all`
+    - `archives:reprint_own` / `archives:reprint_all`
+    - `library:update_own` / `library:update_all`
+    - `library:delete_own` / `library:delete_all`
+  - Administrators group gets `*_all` permissions (can modify any items)
+  - Operators group gets `*_own` permissions (can only modify their own items)
+  - Ownerless items (legacy data without creator) require `*_all` permission
+  - Bulk operations skip items user doesn't have permission to modify
+  - User deletion now offers choice: delete user's items or keep them (become ownerless)
+  - Backend enforces permissions on all API endpoints (not just frontend UI)
+  - Automatic migration upgrades existing groups to new permission model
+- **User Tracking for Archives, Library & Queue** (Issue #206):
+  - Track and display who uploaded each archive file
+  - Track and display who uploaded each library file (File Manager)
+  - Track and display who added each print job to the queue
+  - Shows username on archive cards, library files, queue items, and printer cards (while printing)
+  - Works when authentication is enabled; gracefully hidden when auth is disabled
+  - Database migration adds `created_by_id` columns to `print_archives`, `library_files`, and `print_queue` tables
+- **Separate AMS RFID Permission** (Issue #204):
+  - Added new `printers:ams_rfid` permission for re-reading AMS RFID tags
+  - Allows granting RFID re-read access without full printer control permissions
+  - Operators group includes this permission by default
+  - Available in Settings > Users > Group Editor as a toggleable permission
+- **Schedule Button on Archive Cards** (Issue #208):
+  - Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling
+  - Previously only available in the context menu (right-click)
+  - Respects `queue:create` permission for users with restricted access
+- **Streaming Overlay Improvements** (Issue #164):
+  - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15)
+  - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background
+  - Increased default camera FPS from 10 to 15 for smoother video across all camera views
+- **Simplified Backup/Restore System**:
+  - Complete backup now creates a single ZIP file containing the entire database and all data directories
+  - Includes: database, archives, library files, thumbnails, timelapses, icons, projects, and plate calibration data
+  - Portable backups: works across different installations and data directories
+  - Faster backup/restore: direct file copy instead of JSON export/import
+  - Progress indicator and navigation blocking during backup/restore operations
+  - Removed ~2000 lines of legacy JSON-based backup/restore code
+
+### Fixes
+- **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:
+  - Library now stores relative paths in database for portability
+  - Automatic migration converts existing absolute paths to relative on startup
+  - Thumbnails and files now display correctly after restoring backups
+- **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
+
 ## [0.1.6-final] - 2026-01-31
 ## [0.1.6-final] - 2026-01-31
 
 
 ### New Features
 ### New Features

+ 2 - 1
README.md

@@ -59,7 +59,7 @@
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
-- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
+- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)
@@ -159,6 +159,7 @@
 - Default groups: Administrators, Operators, Viewers
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - JWT tokens with secure password hashing
 - User management (create, edit, delete, groups)
 - User management (create, edit, delete, groups)
+- User activity tracking (who uploaded archives, library files, queued prints, started prints)
 
 
 </td>
 </td>
 </tr>
 </tr>

+ 90 - 6
backend/app/api/routes/archives.py

@@ -8,10 +8,13 @@ from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 
 
@@ -96,6 +99,9 @@ def archive_to_response(
         "energy_kwh": archive.energy_kwh,
         "energy_kwh": archive.energy_kwh,
         "energy_cost": archive.energy_cost,
         "energy_cost": archive.energy_cost,
         "created_at": archive.created_at,
         "created_at": archive.created_at,
+        # User tracking (Issue #206)
+        "created_by_id": archive.created_by_id,
+        "created_by_username": archive.created_by.username if archive.created_by else None,
     }
     }
 
 
     # Add computed time accuracy fields
     # Add computed time accuracy fields
@@ -707,25 +713,42 @@ async def update_archive(
     archive_id: int,
     archive_id: int,
     update_data: ArchiveUpdate,
     update_data: ArchiveUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
     """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
     from sqlalchemy.orm import selectinload
     from sqlalchemy.orm import selectinload
 
 
+    user, can_modify_all = auth_result
+
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id)
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+        .where(PrintArchive.id == archive_id)
     )
     )
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if archive.created_by_id != user.id:
+            raise HTTPException(403, "You can only update your own archives")
+
     for field, value in update_data.model_dump(exclude_unset=True).items():
     for field, value in update_data.model_dump(exclude_unset=True).items():
         setattr(archive, field, value)
         setattr(archive, field, value)
 
 
     await db.commit()
     await db.commit()
 
 
-    # Re-fetch with project relationship loaded after commit
+    # Re-fetch with relationships loaded after commit
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id)
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+        .where(PrintArchive.id == archive_id)
     )
     )
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
 
 
@@ -928,8 +951,30 @@ async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.delete("/{archive_id}")
 @router.delete("/{archive_id}")
-async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
+):
     """Delete an archive."""
     """Delete an archive."""
+    user, can_modify_all = auth_result
+
+    # Get archive first to check ownership
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    # Ownership check
+    if not can_modify_all:
+        if archive.created_by_id != user.id:
+            raise HTTPException(403, "You can only delete your own archives")
+
     service = ArchiveService(db)
     service = ArchiveService(db)
     if not await service.delete_archive(archive_id):
     if not await service.delete_archive(archive_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
@@ -2018,6 +2063,7 @@ async def upload_archive(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Manually upload a 3MF file to archive."""
     """Manually upload a 3MF file to archive."""
     if not file.filename or not file.filename.endswith(".3mf"):
     if not file.filename or not file.filename.endswith(".3mf"):
@@ -2035,6 +2081,7 @@ async def upload_archive(
         archive = await service.archive_print(
         archive = await service.archive_print(
             printer_id=printer_id,
             printer_id=printer_id,
             source_file=temp_path,
             source_file=temp_path,
+            created_by_id=current_user.id if current_user else None,
         )
         )
 
 
         if not archive:
         if not archive:
@@ -2051,6 +2098,7 @@ async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Bulk upload multiple 3MF files to archive."""
     """Bulk upload multiple 3MF files to archive."""
     results = []
     results = []
@@ -2072,6 +2120,7 @@ async def upload_archives_bulk(
             archive = await service.archive_print(
             archive = await service.archive_print(
                 printer_id=printer_id,
                 printer_id=printer_id,
                 source_file=temp_path,
                 source_file=temp_path,
+                created_by_id=current_user.id if current_user else None,
             )
             )
 
 
             if archive:
             if archive:
@@ -2424,6 +2473,12 @@ async def reprint_archive(
     printer_id: int,
     printer_id: int,
     body: ReprintRequest | None = None,
     body: ReprintRequest | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_REPRINT_ALL,
+            Permission.ARCHIVES_REPRINT_OWN,
+        )
+    ),
 ):
 ):
     """Send an archived 3MF file to a printer and start printing."""
     """Send an archived 3MF file to a printer and start printing."""
     from backend.app.main import register_expected_print
     from backend.app.main import register_expected_print
@@ -2435,6 +2490,8 @@ async def reprint_archive(
     )
     )
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
+    user, can_modify_all = auth_result
+
     # Use defaults if no body provided
     # Use defaults if no body provided
     if body is None:
     if body is None:
         body = ReprintRequest()
         body = ReprintRequest()
@@ -2445,6 +2502,11 @@ async def reprint_archive(
     if not archive:
     if not archive:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if archive.created_by_id != user.id:
+            raise HTTPException(403, "You can only reprint your own archives")
+
     # Get printer
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -2476,14 +2538,22 @@ async def reprint_archive(
     # Get FTP retry settings
     # Get FTP retry settings
     ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
     ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
 
+    logger.info(
+        f"Reprint FTP upload starting: printer={printer.name} ({printer.model}), "
+        f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
+        f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
+    )
+
     # Delete existing file if present (avoids 553 error)
     # Delete existing file if present (avoids 553 error)
-    await delete_file_async(
+    logger.debug(f"Deleting existing file {remote_path} if present...")
+    delete_result = await delete_file_async(
         printer.ip_address,
         printer.ip_address,
         printer.access_code,
         printer.access_code,
         remote_path,
         remote_path,
         socket_timeout=ftp_timeout,
         socket_timeout=ftp_timeout,
         printer_model=printer.model,
         printer_model=printer.model,
     )
     )
+    logger.debug(f"Delete result: {delete_result}")
 
 
     if ftp_retry_enabled:
     if ftp_retry_enabled:
         uploaded = await with_ftp_retry(
         uploaded = await with_ftp_retry(
@@ -2509,7 +2579,16 @@ async def reprint_archive(
         )
         )
 
 
     if not uploaded:
     if not uploaded:
-        raise HTTPException(500, "Failed to upload file to printer")
+        logger.error(
+            f"FTP upload failed for reprint: printer={printer.name}, model={printer.model}, "
+            f"ip={printer.ip_address}, file={remote_filename}. "
+            "Check logs above for storage diagnostics and specific error codes."
+        )
+        raise HTTPException(
+            500,
+            "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
+            "See server logs for detailed diagnostics.",
+        )
 
 
     # Register this as an expected print so we don't create a duplicate archive
     # Register this as an expected print so we don't create a duplicate archive
     register_expected_print(printer_id, remote_filename, archive_id)
     register_expected_print(printer_id, remote_filename, archive_id)
@@ -2555,6 +2634,11 @@ async def reprint_archive(
     if not started:
     if not started:
         raise HTTPException(500, "Failed to start print")
         raise HTTPException(500, "Failed to start print")
 
 
+    # Track who started this print (Issue #206)
+    if user:
+        printer_manager.set_current_print_user(printer_id, user.id, user.username)
+        logger.info(f"Reprint started by user: {user.username}")
+
     return {
     return {
         "status": "printing",
         "status": "printing",
         "printer_id": printer_id,
         "printer_id": printer_id,

+ 167 - 36
backend/app/api/routes/library.py

@@ -13,13 +13,21 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, Up
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.auth import (
+    require_auth_if_enabled,
+    require_ownership_permission,
+    require_permission_if_auth_enabled,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project import Project
+from backend.app.models.user import User
 from backend.app.schemas.library import (
 from backend.app.schemas.library import (
     AddToQueueError,
     AddToQueueError,
     AddToQueueRequest,
     AddToQueueRequest,
@@ -75,6 +83,30 @@ def get_library_thumbnails_dir() -> Path:
     return thumbnails_dir
     return thumbnails_dir
 
 
 
 
+def to_relative_path(absolute_path: Path | str) -> str:
+    """Convert an absolute path to a path relative to base_dir for storage."""
+    if not absolute_path:
+        return ""
+    abs_path = Path(absolute_path)
+    base_dir = Path(app_settings.base_dir)
+    try:
+        return str(abs_path.relative_to(base_dir))
+    except ValueError:
+        # Path is not under base_dir, return as-is (shouldn't happen normally)
+        return str(abs_path)
+
+
+def to_absolute_path(relative_path: str | None) -> Path | None:
+    """Convert a relative path (from database) to an absolute path for file operations."""
+    if not relative_path:
+        return None
+    # Handle already-absolute paths (for backwards compatibility during migration)
+    path = Path(relative_path)
+    if path.is_absolute():
+        return path
+    return Path(app_settings.base_dir) / relative_path
+
+
 def calculate_file_hash(file_path: Path) -> str:
 def calculate_file_hash(file_path: Path) -> str:
     """Calculate SHA256 hash of a file."""
     """Calculate SHA256 hash of a file."""
     sha256_hash = hashlib.sha256()
     sha256_hash = hashlib.sha256()
@@ -500,8 +532,16 @@ async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = D
 
 
 
 
 @router.delete("/folders/{folder_id}")
 @router.delete("/folders/{folder_id}")
-async def delete_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
-    """Delete a folder and all its contents (cascade)."""
+async def delete_folder(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
+):
+    """Delete a folder and all its contents (cascade).
+
+    Note: Folders require library:delete_all permission since they don't have
+    ownership tracking.
+    """
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
     folder = result.scalar_one_or_none()
 
 
@@ -563,7 +603,7 @@ async def list_files(
         include_root: If True and folder_id is None, returns files at root level.
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
                      If False and folder_id is None, returns all files.
     """
     """
-    query = select(LibraryFile)
+    query = select(LibraryFile).options(selectinload(LibraryFile.created_by))
 
 
     if folder_id is not None:
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
         query = query.where(LibraryFile.folder_id == folder_id)
@@ -610,6 +650,8 @@ async def list_files(
                 thumbnail_path=f.thumbnail_path,
                 thumbnail_path=f.thumbnail_path,
                 print_count=f.print_count,
                 print_count=f.print_count,
                 duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0,
                 duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0,
+                created_by_id=f.created_by_id,
+                created_by_username=f.created_by.username if f.created_by else None,
                 created_at=f.created_at,
                 created_at=f.created_at,
                 print_name=print_name,
                 print_name=print_name,
                 print_time_seconds=print_time,
                 print_time_seconds=print_time,
@@ -627,6 +669,7 @@ async def upload_file(
     folder_id: int | None = None,
     folder_id: int | None = None,
     generate_stl_thumbnails: bool = Query(default=True),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Upload a file to the library."""
     """Upload a file to the library."""
     try:
     try:
@@ -722,16 +765,17 @@ async def upload_file(
             if generate_stl_thumbnails:
             if generate_stl_thumbnails:
                 thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
                 thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
 
-        # Create database entry
+        # Create database entry (store relative paths for portability)
         library_file = LibraryFile(
         library_file = LibraryFile(
             folder_id=folder_id,
             folder_id=folder_id,
             filename=filename,
             filename=filename,
-            file_path=str(file_path),
+            file_path=to_relative_path(file_path),
             file_type=file_type,
             file_type=file_type,
             file_size=len(content),
             file_size=len(content),
             file_hash=file_hash,
             file_hash=file_hash,
-            thumbnail_path=thumbnail_path,
+            thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
             file_metadata=metadata if metadata else None,
             file_metadata=metadata if metadata else None,
+            created_by_id=current_user.id if current_user else None,
         )
         )
         db.add(library_file)
         db.add(library_file)
         await db.flush()
         await db.flush()
@@ -761,6 +805,7 @@ async def extract_zip_file(
     create_folder_from_zip: bool = Query(default=False),
     create_folder_from_zip: bool = Query(default=False),
     generate_stl_thumbnails: bool = Query(default=True),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Upload and extract a ZIP file to the library.
     """Upload and extract a ZIP file to the library.
 
 
@@ -958,16 +1003,17 @@ async def extract_zip_file(
                         if generate_stl_thumbnails:
                         if generate_stl_thumbnails:
                             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
                             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
 
-                    # Create database entry
+                    # Create database entry (store relative paths for portability)
                     library_file = LibraryFile(
                     library_file = LibraryFile(
                         folder_id=target_folder_id,
                         folder_id=target_folder_id,
                         filename=filename,
                         filename=filename,
-                        file_path=str(file_path),
+                        file_path=to_relative_path(file_path),
                         file_type=file_type,
                         file_type=file_type,
                         file_size=len(file_content),
                         file_size=len(file_content),
                         file_hash=file_hash,
                         file_hash=file_hash,
-                        thumbnail_path=thumbnail_path,
+                        thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
                         file_metadata=metadata if metadata else None,
                         file_metadata=metadata if metadata else None,
+                        created_by_id=current_user.id if current_user else None,
                     )
                     )
                     db.add(library_file)
                     db.add(library_file)
                     await db.flush()
                     await db.flush()
@@ -1062,9 +1108,9 @@ async def batch_generate_stl_thumbnails(
     failed = 0
     failed = 0
 
 
     for stl_file in stl_files:
     for stl_file in stl_files:
-        file_path = Path(stl_file.file_path)
+        file_path = to_absolute_path(stl_file.file_path)
 
 
-        if not file_path.exists():
+        if not file_path or not file_path.exists():
             results.append(
             results.append(
                 BatchThumbnailResult(
                 BatchThumbnailResult(
                     file_id=stl_file.id,
                     file_id=stl_file.id,
@@ -1080,8 +1126,8 @@ async def batch_generate_stl_thumbnails(
             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
 
             if thumbnail_path:
             if thumbnail_path:
-                # Update database
-                stl_file.thumbnail_path = thumbnail_path
+                # Update database with relative path
+                stl_file.thumbnail_path = to_relative_path(thumbnail_path)
                 await db.flush()
                 await db.flush()
                 results.append(
                 results.append(
                     BatchThumbnailResult(
                     BatchThumbnailResult(
@@ -1633,14 +1679,22 @@ async def print_library_file(
     # Get FTP retry settings
     # Get FTP retry settings
     ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
     ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
 
+    logger.info(
+        f"Library print FTP upload starting: printer={printer.name} ({printer.model}), "
+        f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
+        f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
+    )
+
     # Delete existing file if present (avoids 553 error)
     # Delete existing file if present (avoids 553 error)
-    await delete_file_async(
+    logger.debug(f"Deleting existing file {remote_path} if present...")
+    delete_result = await delete_file_async(
         printer.ip_address,
         printer.ip_address,
         printer.access_code,
         printer.access_code,
         remote_path,
         remote_path,
         socket_timeout=ftp_timeout,
         socket_timeout=ftp_timeout,
         printer_model=printer.model,
         printer_model=printer.model,
     )
     )
+    logger.debug(f"Delete result: {delete_result}")
 
 
     # Upload file to printer
     # Upload file to printer
     if ftp_retry_enabled:
     if ftp_retry_enabled:
@@ -1667,7 +1721,16 @@ async def print_library_file(
         )
         )
 
 
     if not uploaded:
     if not uploaded:
-        raise HTTPException(status_code=500, detail="Failed to upload file to printer")
+        logger.error(
+            f"FTP upload failed for library print: printer={printer.name}, model={printer.model}, "
+            f"ip={printer.ip_address}, file={remote_filename}. "
+            "Check logs above for storage diagnostics and specific error codes."
+        )
+        raise HTTPException(
+            status_code=500,
+            detail="Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
+            "See server logs for detailed diagnostics.",
+        )
 
 
     # Register this as an expected print so we don't create a duplicate archive
     # Register this as an expected print so we don't create a duplicate archive
     register_expected_print(printer_id, remote_filename, archive.id)
     register_expected_print(printer_id, remote_filename, archive.id)
@@ -1725,7 +1788,9 @@ async def print_library_file(
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
 async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
 async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
     """Get a file by ID with full details."""
     """Get a file by ID with full details."""
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(
+        select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
+    )
     file = result.scalar_one_or_none()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
@@ -1782,20 +1847,39 @@ async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
         notes=file.notes,
         notes=file.notes,
         duplicates=duplicates if duplicates else None,
         duplicates=duplicates if duplicates else None,
         duplicate_count=duplicate_count,
         duplicate_count=duplicate_count,
+        created_by_id=file.created_by_id,
+        created_by_username=file.created_by.username if file.created_by else None,
         created_at=file.created_at,
         created_at=file.created_at,
         updated_at=file.updated_at,
         updated_at=file.updated_at,
     )
     )
 
 
 
 
 @router.put("/files/{file_id}", response_model=FileResponseSchema)
 @router.put("/files/{file_id}", response_model=FileResponseSchema)
-async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends(get_db)):
+async def update_file(
+    file_id: int,
+    data: FileUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
     """Update a file's metadata."""
     """Update a file's metadata."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only update your own files")
+
     if data.filename is not None:
     if data.filename is not None:
         # Validate filename doesn't contain path separators
         # Validate filename doesn't contain path separators
         if "/" in data.filename or "\\" in data.filename:
         if "/" in data.filename or "\\" in data.filename:
@@ -1833,20 +1917,38 @@ async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends
 
 
 
 
 @router.delete("/files/{file_id}")
 @router.delete("/files/{file_id}")
-async def delete_file(file_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
     """Delete a file."""
     """Delete a file."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only delete your own files")
+
     # Delete actual files
     # Delete actual files
     try:
     try:
-        if file.file_path and os.path.exists(file.file_path):
-            os.remove(file.file_path)
-        if file.thumbnail_path and os.path.exists(file.thumbnail_path):
-            os.remove(file.thumbnail_path)
+        abs_file_path = to_absolute_path(file.file_path)
+        abs_thumb_path = to_absolute_path(file.thumbnail_path)
+        if abs_file_path and abs_file_path.exists():
+            abs_file_path.unlink()
+        if abs_thumb_path and abs_thumb_path.exists():
+            abs_thumb_path.unlink()
     except Exception as e:
     except Exception as e:
         logger.warning(f"Failed to delete file from disk: {e}")
         logger.warning(f"Failed to delete file from disk: {e}")
 
 
@@ -1867,11 +1969,12 @@ async def download_file(file_id: int, db: AsyncSession = Depends(get_db)):
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
-    if not file.file_path or not os.path.exists(file.file_path):
+    abs_path = to_absolute_path(file.file_path)
+    if not abs_path or not abs_path.exists():
         raise HTTPException(status_code=404, detail="File not found on disk")
         raise HTTPException(status_code=404, detail="File not found on disk")
 
 
     return FastAPIFileResponse(
     return FastAPIFileResponse(
-        file.file_path,
+        str(abs_path),
         filename=file.filename,
         filename=file.filename,
         media_type="application/octet-stream",
         media_type="application/octet-stream",
     )
     )
@@ -1886,11 +1989,12 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
-    if not file.thumbnail_path or not os.path.exists(file.thumbnail_path):
+    abs_thumb_path = to_absolute_path(file.thumbnail_path)
+    if not abs_thumb_path or not abs_thumb_path.exists():
         raise HTTPException(status_code=404, detail="Thumbnail not found")
         raise HTTPException(status_code=404, detail="Thumbnail not found")
 
 
     # Detect media type from extension
     # Detect media type from extension
-    thumb_ext = os.path.splitext(file.thumbnail_path)[1].lower()
+    thumb_ext = abs_thumb_path.suffix.lower()
     media_types = {
     media_types = {
         ".png": "image/png",
         ".png": "image/png",
         ".jpg": "image/jpeg",
         ".jpg": "image/jpeg",
@@ -1900,7 +2004,7 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     }
     }
     media_type = media_types.get(thumb_ext, "image/png")
     media_type = media_types.get(thumb_ext, "image/png")
 
 
-    return FastAPIFileResponse(file.thumbnail_path, media_type=media_type)
+    return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)
 
 
 
 
 @router.get("/files/{file_id}/gcode")
 @router.get("/files/{file_id}/gcode")
@@ -1912,17 +2016,18 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
-    if not file.file_path or not os.path.exists(file.file_path):
+    abs_path = to_absolute_path(file.file_path)
+    if not abs_path or not abs_path.exists():
         raise HTTPException(status_code=404, detail="File not found on disk")
         raise HTTPException(status_code=404, detail="File not found on disk")
 
 
     if file.file_type == "gcode":
     if file.file_type == "gcode":
-        return FastAPIFileResponse(file.file_path, media_type="text/plain")
+        return FastAPIFileResponse(str(abs_path), media_type="text/plain")
     elif file.file_type == "3mf":
     elif file.file_type == "3mf":
         # Extract gcode from 3mf
         # Extract gcode from 3mf
         import zipfile
         import zipfile
 
 
         try:
         try:
-            with zipfile.ZipFile(file.file_path, "r") as zf:
+            with zipfile.ZipFile(str(abs_path), "r") as zf:
                 # Find gcode file
                 # Find gcode file
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 if not gcode_files:
                 if not gcode_files:
@@ -1962,28 +2067,54 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
-async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db)):
-    """Delete multiple files and/or folders."""
+async def bulk_delete(
+    data: BulkDeleteRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """Delete multiple files and/or folders.
+
+    Files not owned by the user are skipped (unless user has *_all permission).
+    """
+    user, can_modify_all = auth_result
     deleted_files = 0
     deleted_files = 0
     deleted_folders = 0
     deleted_folders = 0
+    skipped_files = 0
 
 
     # Delete files first
     # Delete files first
     for file_id in data.file_ids:
     for file_id in data.file_ids:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
         file = result.scalar_one_or_none()
         if file:
         if file:
+            # Ownership check
+            if not can_modify_all and file.created_by_id != user.id:
+                skipped_files += 1
+                continue
+
             try:
             try:
-                if file.file_path and os.path.exists(file.file_path):
-                    os.remove(file.file_path)
-                if file.thumbnail_path and os.path.exists(file.thumbnail_path):
-                    os.remove(file.thumbnail_path)
+                abs_file_path = to_absolute_path(file.file_path)
+                abs_thumb_path = to_absolute_path(file.thumbnail_path)
+                if abs_file_path and abs_file_path.exists():
+                    abs_file_path.unlink()
+                if abs_thumb_path and abs_thumb_path.exists():
+                    abs_thumb_path.unlink()
             except Exception as e:
             except Exception as e:
                 logger.warning(f"Failed to delete file from disk: {e}")
                 logger.warning(f"Failed to delete file from disk: {e}")
             await db.delete(file)
             await db.delete(file)
             deleted_files += 1
             deleted_files += 1
 
 
     # Delete folders (cascade will handle contents)
     # Delete folders (cascade will handle contents)
+    # Note: Folders don't have ownership tracking currently, require *_all permission
     for folder_id in data.folder_ids:
     for folder_id in data.folder_ids:
+        if not can_modify_all:
+            # Users without *_all permission cannot delete folders
+            continue
+
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder = result.scalar_one_or_none()
         folder = result.scalar_one_or_none()
         if folder:
         if folder:

+ 76 - 6
backend/app/api/routes/print_queue.py

@@ -12,12 +12,15 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueBulkUpdateResponse,
@@ -140,6 +143,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "completed_at": item.completed_at,
         "completed_at": item.completed_at,
         "error_message": item.error_message,
         "error_message": item.error_message,
         "created_at": item.created_at,
         "created_at": item.created_at,
+        # User tracking (Issue #206)
+        "created_by_id": item.created_by_id,
+        "created_by_username": item.created_by.username if item.created_by else None,
     }
     }
     response = PrintQueueItemResponse(**item_dict)
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
     if item.archive:
@@ -174,6 +180,7 @@ async def list_queue(
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.created_by),
         )
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
     )
@@ -196,6 +203,7 @@ async def list_queue(
 async def add_to_queue(
 async def add_to_queue(
     data: PrintQueueItemCreate,
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Add an item to the print queue."""
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
@@ -298,13 +306,14 @@ async def add_to_queue(
         use_ams=data.use_ams,
         use_ams=data.use_ams,
         position=max_pos + 1,
         position=max_pos + 1,
         status="pending",
         status="pending",
+        created_by_id=current_user.id if current_user else None,
     )
     )
     db.add(item)
     db.add(item)
     await db.commit()
     await db.commit()
     await db.refresh(item)
     await db.refresh(item)
 
 
     # Load relationships for response
     # Load relationships for response
-    await db.refresh(item, ["archive", "printer", "library_file"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
@@ -353,11 +362,20 @@ async def add_to_queue(
 async def bulk_update_queue_items(
 async def bulk_update_queue_items(
     data: PrintQueueBulkUpdate,
     data: PrintQueueBulkUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Bulk update multiple queue items with the same values.
     """Bulk update multiple queue items with the same values.
 
 
     Only pending items can be updated. Non-pending items are skipped.
     Only pending items can be updated. Non-pending items are skipped.
+    Items not owned by the user are also skipped (unless user has *_all permission).
     """
     """
+    user, can_modify_all = auth_result
+
     if not data.item_ids:
     if not data.item_ids:
         raise HTTPException(400, "No item IDs provided")
         raise HTTPException(400, "No item IDs provided")
 
 
@@ -384,6 +402,11 @@ async def bulk_update_queue_items(
             skipped_count += 1
             skipped_count += 1
             continue
             continue
 
 
+        # Ownership check
+        if not can_modify_all and item.created_by_id != user.id:
+            skipped_count += 1
+            continue
+
         for field, value in update_data.items():
         for field, value in update_data.items():
             setattr(item, field, value)
             setattr(item, field, value)
         updated_count += 1
         updated_count += 1
@@ -394,7 +417,8 @@ async def bulk_update_queue_items(
     return PrintQueueBulkUpdateResponse(
     return PrintQueueBulkUpdateResponse(
         updated_count=updated_count,
         updated_count=updated_count,
         skipped_count=skipped_count,
         skipped_count=skipped_count,
-        message=f"Updated {updated_count} items" + (f", skipped {skipped_count} non-pending" if skipped_count else ""),
+        message=f"Updated {updated_count} items"
+        + (f", skipped {skipped_count} non-pending/not-owned" if skipped_count else ""),
     )
     )
 
 
 
 
@@ -407,6 +431,7 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.created_by),
         )
         )
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
@@ -421,13 +446,26 @@ async def update_queue_item(
     item_id: int,
     item_id: int,
     data: PrintQueueItemUpdate,
     data: PrintQueueItemUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Update a queue item."""
     """Update a queue item."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if item.created_by_id != user.id:
+            raise HTTPException(403, "You can only update your own queue items")
+
     if item.status != "pending":
     if item.status != "pending":
         raise HTTPException(400, "Can only update pending items")
         raise HTTPException(400, "Can only update pending items")
 
 
@@ -469,20 +507,36 @@ async def update_queue_item(
         setattr(item, field, value)
         setattr(item, field, value)
 
 
     await db.commit()
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
     logger.info(f"Updated queue item {item_id}")
     logger.info(f"Updated queue item {item_id}")
     return _enrich_response(item)
     return _enrich_response(item)
 
 
 
 
 @router.delete("/{item_id}")
 @router.delete("/{item_id}")
-async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_DELETE_ALL,
+            Permission.QUEUE_DELETE_OWN,
+        )
+    ),
+):
     """Remove an item from the queue."""
     """Remove an item from the queue."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if item.created_by_id != user.id:
+            raise HTTPException(403, "You can only delete your own queue items")
+
     if item.status == "printing":
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
 
@@ -511,13 +565,29 @@ async def reorder_queue(
 
 
 
 
 @router.post("/{item_id}/cancel")
 @router.post("/{item_id}/cancel")
-async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+async def cancel_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
+):
     """Cancel a pending queue item."""
     """Cancel a pending queue item."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if item.created_by_id != user.id:
+            raise HTTPException(403, "You can only cancel your own queue items")
+
     if item.status not in ("pending",):
     if item.status not in ("pending",):
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
 
 
@@ -624,7 +694,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     item.manual_start = False
     await db.commit()
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     return _enrich_response(item)
     return _enrich_response(item)

+ 26 - 2
backend/app/api/routes/printers.py

@@ -175,7 +175,10 @@ async def delete_printer(
 
 
     printer_manager.disconnect_printer(printer_id)
     printer_manager.disconnect_printer(printer_id)
 
 
-    if not delete_archives:
+    if delete_archives:
+        # Delete all archives for this printer
+        await db.execute(sql_delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
+    else:
         # Orphan the archives instead of deleting them
         # Orphan the archives instead of deleting them
         from sqlalchemy import update
         from sqlalchemy import update
 
 
@@ -444,6 +447,27 @@ async def get_printer_status(
     )
     )
 
 
 
 
+@router.get("/{printer_id}/current-print-user")
+async def get_current_print_user(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the user who started the current print (for reprint tracking).
+
+    Returns user info if available, empty object otherwise.
+    This tracks users for reprints (which bypass the queue).
+    For queue-based prints, use the queue item's created_by field instead.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    user_info = printer_manager.get_current_print_user(printer_id)
+    return user_info or {}
+
+
 @router.post("/{printer_id}/refresh-status")
 @router.post("/{printer_id}/refresh-status")
 async def refresh_printer_status(
 async def refresh_printer_status(
     printer_id: int,
     printer_id: int,
@@ -1668,7 +1692,7 @@ async def refresh_ams_slot(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     slot_id: int,
     slot_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""

+ 97 - 2040
backend/app/api/routes/settings.py

@@ -1,36 +1,17 @@
 import io
 import io
-import json
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
-from fastapi import APIRouter, Depends, File, Query, UploadFile
+from fastapi import APIRouter, Depends, File, UploadFile
 from fastapi.responses import JSONResponse, StreamingResponse
 from fastapi.responses import JSONResponse, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
-from backend.app.models.api_key import APIKey
-from backend.app.models.archive import PrintArchive
-from backend.app.models.external_link import ExternalLink
-from backend.app.models.filament import Filament
-from backend.app.models.github_backup import GitHubBackupConfig
-from backend.app.models.group import Group
-from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
-from backend.app.models.notification import NotificationProvider
-from backend.app.models.notification_template import NotificationTemplate
-from backend.app.models.pending_upload import PendingUpload
-from backend.app.models.print_queue import PrintQueueItem
-from backend.app.models.printer import Printer
-from backend.app.models.project import Project
-from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
-from backend.app.models.smart_plug import SmartPlug
-from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
-from backend.app.services.printer_manager import printer_manager
-from backend.app.services.spoolman import init_spoolman_client
 
 
 router = APIRouter(prefix="/settings", tags=["settings"])
 router = APIRouter(prefix="/settings", tags=["settings"])
 
 
@@ -238,2057 +219,133 @@ async def update_spoolman_settings(
 
 
 
 
 @router.get("/backup")
 @router.get("/backup")
-async def export_backup(
-    db: AsyncSession = Depends(get_db),
-    include_settings: bool = Query(True, description="Include app settings"),
-    include_notifications: bool = Query(True, description="Include notification providers"),
-    include_templates: bool = Query(True, description="Include notification templates"),
-    include_smart_plugs: bool = Query(True, description="Include smart plugs"),
-    include_external_links: bool = Query(True, description="Include external sidebar links"),
-    include_printers: bool = Query(False, description="Include printers (without access codes)"),
-    include_plate_calibration: bool = Query(False, description="Include plate detection reference images"),
-    include_filaments: bool = Query(False, description="Include filament inventory"),
-    include_maintenance: bool = Query(
-        False, description="Include maintenance types, per-printer settings, and history"
-    ),
-    include_print_queue: bool = Query(False, description="Include print queue items"),
-    include_archives: bool = Query(False, description="Include print archive metadata"),
-    include_projects: bool = Query(False, description="Include projects with BOM items"),
-    include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
-    include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
-    include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
-    include_users: bool = Query(
-        False, description="Include users (passwords not exported - users will need new passwords)"
-    ),
-    include_groups: bool = Query(False, description="Include groups and user-group assignments"),
-    include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
-):
-    """Export selected data as JSON backup."""
-    backup: dict = {
-        "version": "2.0",
-        "exported_at": datetime.utcnow().isoformat(),
-        "included": [],
-    }
+async def create_backup(db: AsyncSession = Depends(get_db)):
+    """Create a complete backup (database + all files) as a ZIP.
+
+    This is a simplified backup that includes the entire SQLite database
+    and all data directories. It is complete by definition and cannot miss data.
+    """
+    import shutil
+    import tempfile
+
+    from sqlalchemy import text
+
+    from backend.app.core.database import engine
 
 
-    # Settings
-    if include_settings:
-        result = await db.execute(select(Settings))
-        db_settings = result.scalars().all()
-        backup["settings"] = {s.key: s.value for s in db_settings}
-        backup["included"].append("settings")
-
-    # Notification providers
-    if include_notifications:
-        # Build printer ID to serial lookup for cross-system backup
-        printer_id_to_serial: dict[int, str] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_id_to_serial[pr.id] = pr.serial_number
-
-        result = await db.execute(select(NotificationProvider))
-        providers = result.scalars().all()
-        backup["notification_providers"] = []
-        for p in providers:
-            # Use printer_serial for cross-system compatibility
-            provider_printer_id = getattr(p, "printer_id", None)
-            printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None
-
-            backup["notification_providers"].append(
-                {
-                    "name": p.name,
-                    "provider_type": p.provider_type,
-                    "enabled": p.enabled,
-                    "config": json.loads(p.config) if isinstance(p.config, str) else p.config,
-                    "on_print_start": p.on_print_start,
-                    "on_print_complete": p.on_print_complete,
-                    "on_print_failed": p.on_print_failed,
-                    "on_print_stopped": p.on_print_stopped,
-                    "on_print_progress": p.on_print_progress,
-                    "on_printer_offline": p.on_printer_offline,
-                    "on_printer_error": p.on_printer_error,
-                    "on_filament_low": p.on_filament_low,
-                    "on_maintenance_due": p.on_maintenance_due,
-                    "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False),
-                    "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False),
-                    "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False),
-                    "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False),
-                    "on_plate_not_empty": getattr(p, "on_plate_not_empty", True),
-                    "on_queue_job_added": getattr(p, "on_queue_job_added", False),
-                    "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False),
-                    "on_queue_job_started": getattr(p, "on_queue_job_started", False),
-                    "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True),
-                    "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True),
-                    "on_queue_job_failed": getattr(p, "on_queue_job_failed", True),
-                    "on_queue_completed": getattr(p, "on_queue_completed", False),
-                    "quiet_hours_enabled": p.quiet_hours_enabled,
-                    "quiet_hours_start": p.quiet_hours_start,
-                    "quiet_hours_end": p.quiet_hours_end,
-                    "daily_digest_enabled": getattr(p, "daily_digest_enabled", False),
-                    "daily_digest_time": getattr(p, "daily_digest_time", None),
-                    "printer_serial": printer_serial,
-                }
-            )
-        backup["included"].append("notification_providers")
-
-    # Notification templates
-    if include_templates:
-        result = await db.execute(select(NotificationTemplate))
-        templates = result.scalars().all()
-        backup["notification_templates"] = []
-        for t in templates:
-            backup["notification_templates"].append(
-                {
-                    "event_type": t.event_type,
-                    "name": t.name,
-                    "title_template": t.title_template,
-                    "body_template": t.body_template,
-                    "is_default": t.is_default,
-                }
-            )
-        backup["included"].append("notification_templates")
-
-    # Smart plugs
-    if include_smart_plugs:
-        result = await db.execute(select(SmartPlug))
-        plugs = result.scalars().all()
-        backup["smart_plugs"] = []
-
-        # Build printer ID to serial mapping
-        printer_id_to_serial: dict[int, str] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_id_to_serial[pr.id] = pr.serial_number
-
-        for plug in plugs:
-            backup["smart_plugs"].append(
-                {
-                    "name": plug.name,
-                    "plug_type": plug.plug_type,
-                    "ip_address": plug.ip_address,
-                    "ha_entity_id": plug.ha_entity_id,
-                    "ha_power_entity": plug.ha_power_entity,
-                    "ha_energy_today_entity": plug.ha_energy_today_entity,
-                    "ha_energy_total_entity": plug.ha_energy_total_entity,
-                    # MQTT plug fields (legacy)
-                    "mqtt_topic": plug.mqtt_topic,
-                    "mqtt_multiplier": plug.mqtt_multiplier,
-                    # MQTT power fields
-                    "mqtt_power_topic": plug.mqtt_power_topic,
-                    "mqtt_power_path": plug.mqtt_power_path,
-                    "mqtt_power_multiplier": plug.mqtt_power_multiplier,
-                    # MQTT energy fields
-                    "mqtt_energy_topic": plug.mqtt_energy_topic,
-                    "mqtt_energy_path": plug.mqtt_energy_path,
-                    "mqtt_energy_multiplier": plug.mqtt_energy_multiplier,
-                    # MQTT state fields
-                    "mqtt_state_topic": plug.mqtt_state_topic,
-                    "mqtt_state_path": plug.mqtt_state_path,
-                    "mqtt_state_on_value": plug.mqtt_state_on_value,
-                    "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
-                    "enabled": plug.enabled,
-                    "auto_on": plug.auto_on,
-                    "auto_off": plug.auto_off,
-                    "off_delay_mode": plug.off_delay_mode,
-                    "off_delay_minutes": plug.off_delay_minutes,
-                    "off_temp_threshold": plug.off_temp_threshold,
-                    "username": plug.username,
-                    "password": plug.password,
-                    "power_alert_enabled": plug.power_alert_enabled,
-                    "power_alert_high": plug.power_alert_high,
-                    "power_alert_low": plug.power_alert_low,
-                    "schedule_enabled": plug.schedule_enabled,
-                    "schedule_on_time": plug.schedule_on_time,
-                    "schedule_off_time": plug.schedule_off_time,
-                    "show_in_switchbar": plug.show_in_switchbar,
-                    "show_on_printer_card": plug.show_on_printer_card,
-                }
-            )
-        backup["included"].append("smart_plugs")
-
-    # External links
-    if include_external_links:
-        result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order))
-        links = result.scalars().all()
-        backup["external_links"] = []
-        icons_dir = app_settings.base_dir / "icons"
-        for link in links:
-            link_data = {
-                "name": link.name,
-                "url": link.url,
-                "icon": link.icon,
-                "sort_order": link.sort_order,
-            }
-            # Include custom icon file path if exists
-            if link.custom_icon:
-                link_data["custom_icon"] = link.custom_icon
-                icon_path = icons_dir / link.custom_icon
-                if icon_path.exists():
-                    link_data["custom_icon_path"] = f"icons/{link.custom_icon}"
-            backup["external_links"].append(link_data)
-        backup["included"].append("external_links")
-
-    # Printers (access codes only included if explicitly requested)
-    if include_printers:
-        result = await db.execute(select(Printer))
-        printers = result.scalars().all()
-        backup["printers"] = []
-        for printer in printers:
-            printer_data = {
-                "name": printer.name,
-                "serial_number": printer.serial_number,
-                "ip_address": printer.ip_address,
-                "model": printer.model,
-                "location": printer.location,
-                "nozzle_count": printer.nozzle_count,
-                "is_active": printer.is_active,
-                "auto_archive": printer.auto_archive,
-                "print_hours_offset": printer.print_hours_offset,
-                "runtime_seconds": printer.runtime_seconds,
-                "external_camera_url": printer.external_camera_url,
-                "external_camera_type": printer.external_camera_type,
-                "external_camera_enabled": printer.external_camera_enabled,
-                "plate_detection_enabled": printer.plate_detection_enabled,
-                "plate_detection_roi_x": printer.plate_detection_roi_x,
-                "plate_detection_roi_y": printer.plate_detection_roi_y,
-                "plate_detection_roi_w": printer.plate_detection_roi_w,
-                "plate_detection_roi_h": printer.plate_detection_roi_h,
-            }
-            if include_access_codes:
-                printer_data["access_code"] = printer.access_code
-            backup["printers"].append(printer_data)
-        backup["included"].append("printers")
-        if include_access_codes:
-            backup["included"].append("access_codes")
-
-    # Plate calibration references (requires include_printers)
-    if include_printers and include_plate_calibration:
-        plate_cal_dir = app_settings.plate_calibration_dir
-        if plate_cal_dir.exists():
-            backup["plate_calibration"] = {
-                "files": [],
-                "printer_id_to_serial": {},  # Map old printer IDs to serial numbers for restore
-            }
-            for cal_file in plate_cal_dir.iterdir():
-                if cal_file.is_file():
-                    backup["plate_calibration"]["files"].append(cal_file.name)
-                    # Extract printer ID from filename (e.g., "printer_1_ref_0.jpg" -> 1)
-                    if cal_file.name.startswith("printer_"):
-                        parts = cal_file.name.split("_")
-                        if len(parts) >= 2 and parts[1].isdigit():
-                            old_printer_id = int(parts[1])
-                            if old_printer_id not in backup["plate_calibration"]["printer_id_to_serial"]:
-                                # Look up serial number for this printer ID
-                                backup["plate_calibration"]["printer_id_to_serial"][old_printer_id] = (
-                                    printer_id_to_serial.get(old_printer_id)
-                                )
-            if backup["plate_calibration"]["files"]:
-                backup["included"].append("plate_calibration")
-
-    # Filaments
-    if include_filaments:
-        result = await db.execute(select(Filament))
-        filaments = result.scalars().all()
-        backup["filaments"] = []
-        for f in filaments:
-            backup["filaments"].append(
-                {
-                    "name": f.name,
-                    "type": f.type,
-                    "brand": f.brand,
-                    "color": f.color,
-                    "color_hex": f.color_hex,
-                    "cost_per_kg": f.cost_per_kg,
-                    "spool_weight_g": f.spool_weight_g,
-                    "currency": f.currency,
-                    "density": f.density,
-                    "print_temp_min": f.print_temp_min,
-                    "print_temp_max": f.print_temp_max,
-                    "bed_temp_min": f.bed_temp_min,
-                    "bed_temp_max": f.bed_temp_max,
-                }
-            )
-        backup["included"].append("filaments")
-
-    # Maintenance types and records
-    if include_maintenance:
-        # Maintenance types
-        result = await db.execute(select(MaintenanceType))
-        types = result.scalars().all()
-        backup["maintenance_types"] = []
-        for mt in types:
-            backup["maintenance_types"].append(
-                {
-                    "name": mt.name,
-                    "description": mt.description,
-                    "default_interval_hours": mt.default_interval_hours,
-                    "interval_type": mt.interval_type,
-                    "icon": mt.icon,
-                    "is_system": mt.is_system,
-                }
-            )
-        backup["included"].append("maintenance_types")
-
-        # Printer maintenance settings (per-printer custom intervals, enabled status, last performed)
-        result = await db.execute(select(PrinterMaintenance))
-        printer_maint = result.scalars().all()
-        backup["printer_maintenance"] = []
-
-        # Build lookups for printer serial and maintenance type name
-        printer_id_to_serial: dict[int, str] = {}
-        maint_type_id_to_name: dict[int, str] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_id_to_serial[pr.id] = pr.serial_number
-        for mt in types:
-            maint_type_id_to_name[mt.id] = mt.name
-
-        for pm in printer_maint:
-            backup["printer_maintenance"].append(
-                {
-                    "printer_serial": printer_id_to_serial.get(pm.printer_id),
-                    "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id),
-                    "custom_interval_hours": pm.custom_interval_hours,
-                    "custom_interval_type": pm.custom_interval_type,
-                    "enabled": pm.enabled,
-                    "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None,
-                    "last_performed_hours": pm.last_performed_hours,
-                }
-            )
-        backup["included"].append("printer_maintenance")
-
-        # Maintenance history
-        result = await db.execute(select(MaintenanceHistory))
-        history = result.scalars().all()
-        backup["maintenance_history"] = []
-
-        # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping
-        pm_id_to_info: dict[int, tuple[str | None, str | None]] = {}
-        for pm in printer_maint:
-            pm_id_to_info[pm.id] = (
-                printer_id_to_serial.get(pm.printer_id),
-                maint_type_id_to_name.get(pm.maintenance_type_id),
-            )
-
-        for mh in history:
-            info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None))
-            backup["maintenance_history"].append(
-                {
-                    "printer_serial": info[0],
-                    "maintenance_type_name": info[1],
-                    "performed_at": mh.performed_at.isoformat() if mh.performed_at else None,
-                    "hours_at_maintenance": mh.hours_at_maintenance,
-                    "notes": mh.notes,
-                }
-            )
-        backup["included"].append("maintenance_history")
-
-    # Print queue
-    if include_print_queue:
-        result = await db.execute(select(PrintQueueItem))
-        queue_items = result.scalars().all()
-        backup["print_queue"] = []
-
-        # Build lookups
-        printer_id_to_serial: dict[int, str] = {}
-        archive_id_to_hash: dict[int, str | None] = {}
-        project_id_to_name: dict[int, str] = {}
-
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_id_to_serial[pr.id] = pr.serial_number
-        ar_result = await db.execute(select(PrintArchive))
-        for ar in ar_result.scalars().all():
-            archive_id_to_hash[ar.id] = ar.content_hash
-        proj_result = await db.execute(select(Project))
-        for proj in proj_result.scalars().all():
-            project_id_to_name[proj.id] = proj.name
-
-        for qi in queue_items:
-            backup["print_queue"].append(
-                {
-                    "printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None,
-                    "archive_hash": archive_id_to_hash.get(qi.archive_id),
-                    "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None,
-                    "position": qi.position,
-                    "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
-                    "require_previous_success": qi.require_previous_success,
-                    "auto_off_after": qi.auto_off_after,
-                    "manual_start": qi.manual_start,
-                    "ams_mapping": qi.ams_mapping,
-                    "plate_id": qi.plate_id,
-                    "bed_levelling": qi.bed_levelling,
-                    "flow_cali": qi.flow_cali,
-                    "vibration_cali": qi.vibration_cali,
-                    "layer_inspect": qi.layer_inspect,
-                    "timelapse": qi.timelapse,
-                    "use_ams": qi.use_ams,
-                    "status": qi.status,
-                    "started_at": qi.started_at.isoformat() if qi.started_at else None,
-                    "completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
-                    "error_message": qi.error_message,
-                }
-            )
-        backup["included"].append("print_queue")
-
-    # Collect files for ZIP (icons + archives + project attachments)
-    backup_files: list[tuple[str, Path]] = []  # (zip_path, local_path)
     base_dir = app_settings.base_dir
     base_dir = app_settings.base_dir
+    db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
 
 
-    # Add external link icon files
-    if include_external_links and "external_links" in backup:
-        icons_dir = base_dir / "icons"
-        for link_data in backup["external_links"]:
-            if "custom_icon_path" in link_data:
-                icon_path = icons_dir / link_data["custom_icon"]
-                if icon_path.exists():
-                    backup_files.append((link_data["custom_icon_path"], icon_path))
-
-    # Add plate calibration reference images
-    if "plate_calibration" in backup:
-        plate_cal_dir = app_settings.plate_calibration_dir
-        plate_cal_data = backup["plate_calibration"]
-        # Support both old list format and new dict format
-        filenames = plate_cal_data.get("files", []) if isinstance(plate_cal_data, dict) else plate_cal_data
-        for filename in filenames:
-            file_path = plate_cal_dir / filename
-            if file_path.exists():
-                backup_files.append((f"plate_calibration/{filename}", file_path))
-
-    # Print archives with file paths for ZIP
-    if include_archives:
-        result = await db.execute(select(PrintArchive))
-        archives = result.scalars().all()
-        backup["archives"] = []
-
-        # Build project ID to name mapping for archive export
-        project_id_to_name: dict[int, str] = {}
-        if include_projects:
-            proj_result = await db.execute(select(Project))
-            for proj in proj_result.scalars().all():
-                project_id_to_name[proj.id] = proj.name
-
-        # Build printer ID to serial mapping for archive export
-        printer_id_to_serial: dict[int, str] = {}
-        if include_printers:
-            printer_result = await db.execute(select(Printer))
-            for pr in printer_result.scalars().all():
-                printer_id_to_serial[pr.id] = pr.serial_number
-
-        for a in archives:
-            archive_data = {
-                "filename": a.filename,
-                "project_name": project_id_to_name.get(a.project_id) if a.project_id else None,
-                "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None,
-                "file_size": a.file_size,
-                "content_hash": a.content_hash,
-                "print_name": a.print_name,
-                "print_time_seconds": a.print_time_seconds,
-                "filament_used_grams": a.filament_used_grams,
-                "filament_type": a.filament_type,
-                "filament_color": a.filament_color,
-                "layer_height": a.layer_height,
-                "total_layers": a.total_layers,
-                "nozzle_diameter": a.nozzle_diameter,
-                "bed_temperature": a.bed_temperature,
-                "nozzle_temperature": a.nozzle_temperature,
-                "status": a.status,
-                "started_at": a.started_at.isoformat() if a.started_at else None,
-                "completed_at": a.completed_at.isoformat() if a.completed_at else None,
-                "makerworld_url": a.makerworld_url,
-                "designer": a.designer,
-                "external_url": a.external_url,
-                "is_favorite": a.is_favorite,
-                "tags": a.tags,
-                "notes": a.notes,
-                "cost": a.cost,
-                "failure_reason": a.failure_reason,
-                "quantity": a.quantity,
-                "energy_kwh": a.energy_kwh,
-                "energy_cost": a.energy_cost,
-                "extra_data": a.extra_data,
-                "photos": a.photos,
-            }
+    with tempfile.TemporaryDirectory() as temp_dir:
+        temp_path = Path(temp_dir)
 
 
-            # Collect file paths for ZIP
-            if a.file_path:
-                file_path = base_dir / a.file_path
-                if file_path.exists():
-                    archive_data["file_path"] = a.file_path
-                    backup_files.append((a.file_path, file_path))
-
-            if a.thumbnail_path:
-                thumb_path = base_dir / a.thumbnail_path
-                if thumb_path.exists():
-                    archive_data["thumbnail_path"] = a.thumbnail_path
-                    backup_files.append((a.thumbnail_path, thumb_path))
-
-            if a.timelapse_path:
-                timelapse_path = base_dir / a.timelapse_path
-                if timelapse_path.exists():
-                    archive_data["timelapse_path"] = a.timelapse_path
-                    backup_files.append((a.timelapse_path, timelapse_path))
-
-            if a.source_3mf_path:
-                source_path = base_dir / a.source_3mf_path
-                if source_path.exists():
-                    archive_data["source_3mf_path"] = a.source_3mf_path
-                    backup_files.append((a.source_3mf_path, source_path))
-
-            if a.f3d_path:
-                f3d_path = base_dir / a.f3d_path
-                if f3d_path.exists():
-                    archive_data["f3d_path"] = a.f3d_path
-                    backup_files.append((a.f3d_path, f3d_path))
-
-            # Include photos
-            if a.photos:
-                for photo in a.photos:
-                    photo_path = base_dir / "archive" / "photos" / photo
-                    if photo_path.exists():
-                        zip_photo_path = f"archive/photos/{photo}"
-                        backup_files.append((zip_photo_path, photo_path))
-
-            backup["archives"].append(archive_data)
-        backup["included"].append("archives")
-
-    # Projects with BOM items
-    if include_projects:
-        result = await db.execute(select(Project))
-        projects = result.scalars().all()
-        backup["projects"] = []
-
-        for p in projects:
-            # Get BOM items for this project
-            bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id))
-            bom_items = bom_result.scalars().all()
-
-            project_data = {
-                "name": p.name,
-                "description": p.description,
-                "color": p.color,
-                "status": p.status,
-                "target_count": p.target_count,
-                "notes": p.notes,
-                "tags": p.tags,
-                "due_date": p.due_date.isoformat() if p.due_date else None,
-                "priority": p.priority,
-                "budget": p.budget,
-                "is_template": p.is_template,
-                "template_source_id": p.template_source_id,
-                "parent_id": p.parent_id,
-                "bom_items": [
-                    {
-                        "name": item.name,
-                        "quantity_needed": item.quantity_needed,
-                        "quantity_acquired": item.quantity_acquired,
-                        "unit_price": item.unit_price,
-                        "sourcing_url": item.sourcing_url,
-                        "stl_filename": item.stl_filename,
-                        "remarks": item.remarks,
-                        "sort_order": item.sort_order,
-                    }
-                    for item in bom_items
-                ],
-            }
+        # 1. Checkpoint WAL to ensure all data is in main db file
+        async with engine.begin() as conn:
+            await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
 
 
-            # Include attachment files for ZIP
-            if p.attachments:
-                project_data["attachments"] = p.attachments
-                attachments_dir = base_dir / "projects" / str(p.id) / "attachments"
-                for att in p.attachments:
-                    att_path = attachments_dir / att.get("filename", "")
-                    if att_path.exists():
-                        zip_path = f"projects/{p.id}/attachments/{att['filename']}"
-                        backup_files.append((zip_path, att_path))
-
-            backup["projects"].append(project_data)
-        backup["included"].append("projects")
-
-    # Pending uploads (virtual printer queue mode)
-    if include_pending_uploads:
-        result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
-        pending_uploads = result.scalars().all()
-        backup["pending_uploads"] = []
-
-        for p in pending_uploads:
-            upload_data = {
-                "filename": p.filename,
-                "file_size": p.file_size,
-                "source_ip": p.source_ip,
-                "status": p.status,
-                "tags": p.tags,
-                "notes": p.notes,
-                "project_id": p.project_id,
-                "uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None,
-            }
+        # 2. Copy database file
+        shutil.copy2(db_path, temp_path / "bambuddy.db")
 
 
-            # Include the actual file if it exists
-            if p.file_path:
-                file_path = Path(p.file_path)
-                if file_path.exists():
-                    # Store relative path for ZIP
-                    rel_path = f"pending_uploads/{p.filename}"
-                    upload_data["file_path"] = rel_path
-                    backup_files.append((rel_path, file_path))
-
-            backup["pending_uploads"].append(upload_data)
-        backup["included"].append("pending_uploads")
-
-    # API keys (note: key_hash cannot be restored, new keys must be generated)
-    if include_api_keys:
-        # Build printer ID to serial mapping for cross-system compatibility
-        printer_id_to_serial: dict[int, str] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_id_to_serial[pr.id] = pr.serial_number
-
-        result = await db.execute(select(APIKey))
-        api_keys = result.scalars().all()
-        backup["api_keys"] = []
-        for key in api_keys:
-            # Convert printer_ids from list of IDs to list of serials
-            printer_serials = None
-            if key.printer_ids:
-                printer_serials = [
-                    printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial
-                ]
-
-            backup["api_keys"].append(
-                {
-                    "name": key.name,
-                    "key_prefix": key.key_prefix,  # For identification only
-                    "can_queue": key.can_queue,
-                    "can_control_printer": key.can_control_printer,
-                    "can_read_status": key.can_read_status,
-                    "printer_serials": printer_serials,  # Use serials instead of IDs
-                    "enabled": key.enabled,
-                    "expires_at": key.expires_at.isoformat() if key.expires_at else None,
-                }
-            )
-        backup["included"].append("api_keys")
-
-    # Users (note: passwords not exported for security - users will need new passwords on import)
-    if include_users:
-        result = await db.execute(select(User))
-        users = result.scalars().all()
-        backup["users"] = []
-        for user in users:
-            backup["users"].append(
-                {
-                    "username": user.username,
-                    "role": user.role,
-                    "is_active": user.is_active,
-                    "groups": [g.name for g in user.groups],
-                    # password_hash intentionally not exported for security
-                }
-            )
-        backup["included"].append("users")
-
-    # Groups (permission groups)
-    if include_groups:
-        result = await db.execute(select(Group))
-        groups = result.scalars().all()
-        backup["groups"] = []
-        for group in groups:
-            backup["groups"].append(
-                {
-                    "name": group.name,
-                    "description": group.description,
-                    "permissions": group.permissions,
-                    "is_system": group.is_system,
-                }
-            )
-        backup["included"].append("groups")
-
-    # GitHub backup configuration
-    if include_github_backup:
-        result = await db.execute(select(GitHubBackupConfig).limit(1))
-        config = result.scalar_one_or_none()
-        if config:
-            backup["github_backup"] = {
-                "repository_url": config.repository_url,
-                # access_token intentionally not exported for security
-                "branch": config.branch,
-                "schedule_enabled": config.schedule_enabled,
-                "schedule_type": config.schedule_type,
-                "backup_kprofiles": config.backup_kprofiles,
-                "backup_cloud_profiles": config.backup_cloud_profiles,
-                "backup_settings": config.backup_settings,
-                "enabled": config.enabled,
-            }
-            backup["included"].append("github_backup")
+        # 3. Copy data directories (if they exist)
+        dirs_to_backup = [
+            ("archive", base_dir / "archive"),
+            ("virtual_printer", base_dir / "virtual_printer"),
+            ("plate_calibration", app_settings.plate_calibration_dir),
+            ("icons", base_dir / "icons"),
+            ("projects", base_dir / "projects"),
+        ]
 
 
-    # If there are files to include (icons or archives), create ZIP file
-    if backup_files:
+        for name, src_dir in dirs_to_backup:
+            if src_dir.exists() and any(src_dir.iterdir()):
+                shutil.copytree(src_dir, temp_path / name)
+
+        # 4. Create ZIP
         zip_buffer = io.BytesIO()
         zip_buffer = io.BytesIO()
         with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
         with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
-            # Add backup.json
-            zf.writestr("backup.json", json.dumps(backup, indent=2))
-
-            # Add all backup files (icons, archives, etc.)
-            added_files = set()
-            for zip_path, local_path in backup_files:
-                if zip_path not in added_files and local_path.exists():
-                    try:
-                        zf.write(local_path, zip_path)
-                        added_files.add(zip_path)
-                    except Exception:
-                        pass  # Skip files that can't be read
+            for file_path in temp_path.rglob("*"):
+                if file_path.is_file():
+                    arcname = file_path.relative_to(temp_path)
+                    zf.write(file_path, arcname)
 
 
         zip_buffer.seek(0)
         zip_buffer.seek(0)
         filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
         filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
+
         return StreamingResponse(
         return StreamingResponse(
             zip_buffer,
             zip_buffer,
             media_type="application/zip",
             media_type="application/zip",
             headers={"Content-Disposition": f"attachment; filename={filename}"},
             headers={"Content-Disposition": f"attachment; filename={filename}"},
         )
         )
 
 
-    # Otherwise return JSON
-    return JSONResponse(
-        content=backup,
-        headers={
-            "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
-        },
-    )
-
 
 
 @router.post("/restore")
 @router.post("/restore")
-async def import_backup(
+async def restore_backup(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
-    overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing."""
-    try:
-        content = await file.read()
-        base_dir = app_settings.base_dir
-        files_restored = 0
-        # Store plate calibration files for later (need printer ID remapping after printers restored)
-        plate_cal_files: dict[str, bytes] = {}
-
-        # Check if it's a ZIP file
-        if file.filename and file.filename.endswith(".zip"):
-            try:
-                zip_buffer = io.BytesIO(content)
-                with zipfile.ZipFile(zip_buffer, "r") as zf:
-                    # Extract backup.json
-                    if "backup.json" not in zf.namelist():
-                        return {"success": False, "message": "Invalid ZIP: missing backup.json"}
-
-                    backup_content = zf.read("backup.json")
-                    backup = json.loads(backup_content.decode("utf-8"))
-
-                    # Extract all other files to base_dir
-                    for zip_path in zf.namelist():
-                        if zip_path == "backup.json":
-                            continue
-                        # Ensure path is safe (no path traversal)
-                        if ".." in zip_path or zip_path.startswith("/"):
-                            continue
-                        # Plate calibration files - store for later processing after printers are restored
-                        if zip_path.startswith("plate_calibration/"):
-                            filename = zip_path.replace("plate_calibration/", "", 1)
-                            if filename:  # Skip directory entries
-                                plate_cal_files[filename] = zf.read(zip_path)
-                            continue
-                        target_path = base_dir / zip_path
-                        target_path.parent.mkdir(parents=True, exist_ok=True)
-                        with zf.open(zip_path) as src, open(target_path, "wb") as dst:
-                            dst.write(src.read())
-                            files_restored += 1
-            except zipfile.BadZipFile:
-                return {"success": False, "message": "Invalid ZIP file"}
-        else:
-            backup = json.loads(content.decode("utf-8"))
-    except json.JSONDecodeError as e:
-        return {"success": False, "message": f"Invalid JSON: {str(e)}"}
-    except Exception as e:
-        return {"success": False, "message": f"Invalid backup file: {str(e)}"}
-
-    restored = {
-        "settings": 0,
-        "notification_providers": 0,
-        "notification_templates": 0,
-        "smart_plugs": 0,
-        "external_links": 0,
-        "printers": 0,
-        "filaments": 0,
-        "maintenance_types": 0,
-        "projects": 0,
-        "pending_uploads": 0,
-        "users": 0,
-        "groups": 0,
-        "github_backup": 0,
-    }
-    skipped = {
-        "settings": 0,
-        "notification_providers": 0,
-        "notification_templates": 0,
-        "smart_plugs": 0,
-        "external_links": 0,
-        "printers": 0,
-        "filaments": 0,
-        "maintenance_types": 0,
-        "archives": 0,
-        "projects": 0,
-        "pending_uploads": 0,
-        "users": 0,
-        "groups": 0,
-        "github_backup": 0,
-    }
-    skipped_details = {
-        "notification_providers": [],
-        "smart_plugs": [],
-        "external_links": [],
-        "printers": [],
-        "filaments": [],
-        "maintenance_types": [],
-        "archives": [],
-        "projects": [],
-        "pending_uploads": [],
-        "users": [],
-        "groups": [],
-    }
+    """Restore from a complete backup ZIP.
 
 
-    # Restore settings (always overwrites)
-    if "settings" in backup:
-        for key, value in backup["settings"].items():
-            # Convert value to proper string format for storage
-            if isinstance(value, bool):
-                str_value = "true" if value else "false"
-            elif value is None:
-                str_value = "None"
-            else:
-                str_value = str(value)
-            await set_setting(db, key, str_value)
-            restored["settings"] += 1
-        # Flush settings to ensure they're persisted before continuing
-        await db.flush()
-
-    # Restore printers FIRST (skip or overwrite duplicates by serial_number)
-    # Nearly everything in the app references printers, so they must be imported first
-    if "printers" in backup:
-        for printer_data in backup["printers"]:
-            result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    existing.name = printer_data["name"]
-                    existing.ip_address = printer_data["ip_address"]
-                    existing.model = printer_data.get("model")
-                    existing.location = printer_data.get("location")
-                    existing.nozzle_count = printer_data.get("nozzle_count", 1)
-                    existing.auto_archive = printer_data.get("auto_archive", True)
-                    existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0)
-                    existing.runtime_seconds = printer_data.get("runtime_seconds", 0)
-
-                    # If backup includes access_code, also update access_code and is_active
-                    backup_access_code = printer_data.get("access_code")
-                    if backup_access_code and backup_access_code != "CHANGE_ME":
-                        existing.access_code = backup_access_code
-                        is_active_val = printer_data.get("is_active", False)
-                        if isinstance(is_active_val, str):
-                            is_active_val = is_active_val.lower() == "true"
-                        existing.is_active = is_active_val
-
-                    # Restore external camera settings
-                    existing.external_camera_url = printer_data.get("external_camera_url")
-                    existing.external_camera_type = printer_data.get("external_camera_type")
-                    existing.external_camera_enabled = printer_data.get("external_camera_enabled", False)
-
-                    # Restore plate detection settings
-                    existing.plate_detection_enabled = printer_data.get("plate_detection_enabled", False)
-                    existing.plate_detection_roi_x = printer_data.get("plate_detection_roi_x")
-                    existing.plate_detection_roi_y = printer_data.get("plate_detection_roi_y")
-                    existing.plate_detection_roi_w = printer_data.get("plate_detection_roi_w")
-                    existing.plate_detection_roi_h = printer_data.get("plate_detection_roi_h")
-
-                    restored["printers"] += 1
-                else:
-                    skipped["printers"] += 1
-                    skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})")
-            else:
-                # Use access code from backup if provided, otherwise require manual setup
-                access_code = printer_data.get("access_code")
-                has_access_code = access_code and access_code != "CHANGE_ME"
-                is_active_from_backup = printer_data.get("is_active", False)
-                # Handle bool or string "true"/"false"
-                if isinstance(is_active_from_backup, str):
-                    is_active_from_backup = is_active_from_backup.lower() == "true"
-
-                printer = Printer(
-                    name=printer_data["name"],
-                    serial_number=printer_data["serial_number"],
-                    ip_address=printer_data["ip_address"],
-                    access_code=access_code if has_access_code else "CHANGE_ME",
-                    model=printer_data.get("model"),
-                    location=printer_data.get("location"),
-                    nozzle_count=printer_data.get("nozzle_count", 1),
-                    is_active=is_active_from_backup if has_access_code else False,
-                    auto_archive=printer_data.get("auto_archive", True),
-                    print_hours_offset=printer_data.get("print_hours_offset", 0.0),
-                    runtime_seconds=printer_data.get("runtime_seconds", 0),
-                    external_camera_url=printer_data.get("external_camera_url"),
-                    external_camera_type=printer_data.get("external_camera_type"),
-                    external_camera_enabled=printer_data.get("external_camera_enabled", False),
-                    plate_detection_enabled=printer_data.get("plate_detection_enabled", False),
-                    plate_detection_roi_x=printer_data.get("plate_detection_roi_x"),
-                    plate_detection_roi_y=printer_data.get("plate_detection_roi_y"),
-                    plate_detection_roi_w=printer_data.get("plate_detection_roi_w"),
-                    plate_detection_roi_h=printer_data.get("plate_detection_roi_h"),
-                )
-                db.add(printer)
-                restored["printers"] += 1
-        # Flush printers so other sections can look them up
-        await db.flush()
-
-    # Restore plate calibration files (remap printer IDs based on serial numbers)
-    if plate_cal_files:
-        # Build serial_number -> new_printer_id mapping
-        serial_to_new_id: dict[str, int] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            serial_to_new_id[pr.serial_number] = pr.id
-
-        # Get old_id -> serial mapping from backup (supports both old list format and new dict format)
-        plate_cal_data = backup.get("plate_calibration", {})
-        if isinstance(plate_cal_data, dict):
-            old_id_to_serial: dict[int, str | None] = {
-                int(k): v for k, v in plate_cal_data.get("printer_id_to_serial", {}).items()
-            }
-        else:
-            old_id_to_serial = {}
-
-        # Build old_id -> new_id mapping
-        old_id_to_new_id: dict[int, int] = {}
-        for old_id, serial in old_id_to_serial.items():
-            if serial and serial in serial_to_new_id:
-                old_id_to_new_id[old_id] = serial_to_new_id[serial]
-
-        app_settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True)
-
-        for filename, file_data in plate_cal_files.items():
-            # Parse old printer ID from filename (e.g., "printer_3_ref_0.jpg" -> 3)
-            new_filename = filename
-            if filename.startswith("printer_"):
-                parts = filename.split("_")
-                if len(parts) >= 2 and parts[1].isdigit():
-                    old_printer_id = int(parts[1])
-                    if old_printer_id in old_id_to_new_id:
-                        new_printer_id = old_id_to_new_id[old_printer_id]
-                        # Replace old ID with new ID in filename
-                        new_filename = filename.replace(f"printer_{old_printer_id}_", f"printer_{new_printer_id}_", 1)
-
-            target_path = app_settings.plate_calibration_dir / new_filename
-            with open(target_path, "wb") as f:
-                f.write(file_data)
-            files_restored += 1
-
-    # Restore notification providers (skip or overwrite duplicates by name)
-    # Build printer serial to ID lookup (printers were restored first)
-    if "notification_providers" in backup:
-        printer_serial_to_id: dict[str, int] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        for provider_data in backup["notification_providers"]:
-            # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
-            printer_serial = provider_data.get("printer_serial")
-            printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id")
-
-            result = await db.execute(
-                select(NotificationProvider).where(NotificationProvider.name == provider_data["name"])
-            )
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    # Update existing provider
-                    existing.provider_type = provider_data["provider_type"]
-                    existing.enabled = provider_data.get("enabled", True)
-                    existing.config = json.dumps(provider_data.get("config", {}))
-                    existing.on_print_start = provider_data.get("on_print_start", False)
-                    existing.on_print_complete = provider_data.get("on_print_complete", True)
-                    existing.on_print_failed = provider_data.get("on_print_failed", True)
-                    existing.on_print_stopped = provider_data.get("on_print_stopped", True)
-                    existing.on_print_progress = provider_data.get("on_print_progress", False)
-                    existing.on_printer_offline = provider_data.get("on_printer_offline", False)
-                    existing.on_printer_error = provider_data.get("on_printer_error", False)
-                    existing.on_filament_low = provider_data.get("on_filament_low", False)
-                    existing.on_maintenance_due = provider_data.get("on_maintenance_due", False)
-                    existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False)
-                    existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False)
-                    existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False)
-                    existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False)
-                    existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True)
-                    existing.on_queue_job_added = provider_data.get("on_queue_job_added", False)
-                    existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False)
-                    existing.on_queue_job_started = provider_data.get("on_queue_job_started", False)
-                    existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True)
-                    existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True)
-                    existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True)
-                    existing.on_queue_completed = provider_data.get("on_queue_completed", False)
-                    existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False)
-                    existing.quiet_hours_start = provider_data.get("quiet_hours_start")
-                    existing.quiet_hours_end = provider_data.get("quiet_hours_end")
-                    existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False)
-                    existing.daily_digest_time = provider_data.get("daily_digest_time")
-                    existing.printer_id = printer_id
-                    restored["notification_providers"] += 1
-                else:
-                    skipped["notification_providers"] += 1
-                    skipped_details["notification_providers"].append(provider_data["name"])
-            else:
-                provider = NotificationProvider(
-                    name=provider_data["name"],
-                    provider_type=provider_data["provider_type"],
-                    enabled=provider_data.get("enabled", True),
-                    config=json.dumps(provider_data.get("config", {})),
-                    on_print_start=provider_data.get("on_print_start", False),
-                    on_print_complete=provider_data.get("on_print_complete", True),
-                    on_print_failed=provider_data.get("on_print_failed", True),
-                    on_print_stopped=provider_data.get("on_print_stopped", True),
-                    on_print_progress=provider_data.get("on_print_progress", False),
-                    on_printer_offline=provider_data.get("on_printer_offline", False),
-                    on_printer_error=provider_data.get("on_printer_error", False),
-                    on_filament_low=provider_data.get("on_filament_low", False),
-                    on_maintenance_due=provider_data.get("on_maintenance_due", False),
-                    on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False),
-                    on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False),
-                    on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False),
-                    on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False),
-                    on_plate_not_empty=provider_data.get("on_plate_not_empty", True),
-                    on_queue_job_added=provider_data.get("on_queue_job_added", False),
-                    on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False),
-                    on_queue_job_started=provider_data.get("on_queue_job_started", False),
-                    on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True),
-                    on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True),
-                    on_queue_job_failed=provider_data.get("on_queue_job_failed", True),
-                    on_queue_completed=provider_data.get("on_queue_completed", False),
-                    quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False),
-                    quiet_hours_start=provider_data.get("quiet_hours_start"),
-                    quiet_hours_end=provider_data.get("quiet_hours_end"),
-                    daily_digest_enabled=provider_data.get("daily_digest_enabled", False),
-                    daily_digest_time=provider_data.get("daily_digest_time"),
-                    printer_id=printer_id,
-                )
-                db.add(provider)
-                restored["notification_providers"] += 1
-
-    # Restore notification templates (update existing by event_type)
-    if "notification_templates" in backup:
-        for template_data in backup["notification_templates"]:
-            result = await db.execute(
-                select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"])
-            )
-            existing = result.scalar_one_or_none()
-            if existing:
-                # Update existing template
-                existing.name = template_data.get("name", existing.name)
-                existing.title_template = template_data.get("title_template", existing.title_template)
-                existing.body_template = template_data.get("body_template", existing.body_template)
-                existing.is_default = template_data.get("is_default", False)
-            else:
-                template = NotificationTemplate(
-                    event_type=template_data["event_type"],
-                    name=template_data["name"],
-                    title_template=template_data["title_template"],
-                    body_template=template_data["body_template"],
-                    is_default=template_data.get("is_default", False),
-                )
-                db.add(template)
-            restored["notification_templates"] += 1
-
-    # Restore smart plugs (skip or overwrite duplicates by IP)
-    # Note: Smart plugs reference printers, so printers should be restored first
-    if "smart_plugs" in backup:
-        # Build printer serial to ID lookup
-        printer_serial_to_id: dict[str, int] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        for plug_data in backup["smart_plugs"]:
-            # Look up printer_id from serial (supports both old printer_id and new printer_serial format)
-            printer_serial = plug_data.get("printer_serial")
-            printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id")
-
-            # Determine plug type (default to tasmota for backwards compatibility)
-            plug_type = plug_data.get("plug_type", "tasmota")
-
-            # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT)
-            existing = None
-            plug_identifier = None
-            if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
-                result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
-                existing = result.scalar_one_or_none()
-                plug_identifier = plug_data["ha_entity_id"]
-            elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")):
-                # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy)
-                power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")
-                result = await db.execute(
-                    select(SmartPlug).where(
-                        (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic)
-                    )
-                )
-                existing = result.scalar_one_or_none()
-                plug_identifier = power_topic
-            elif plug_data.get("ip_address"):
-                result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
-                existing = result.scalar_one_or_none()
-                plug_identifier = plug_data["ip_address"]
-            else:
-                # Skip invalid plug data
-                continue
-
-            if existing:
-                if overwrite:
-                    existing.name = plug_data["name"]
-                    existing.plug_type = plug_type
-                    existing.ha_entity_id = plug_data.get("ha_entity_id")
-                    existing.ha_power_entity = plug_data.get("ha_power_entity")
-                    existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
-                    existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
-                    # MQTT fields (legacy)
-                    existing.mqtt_topic = plug_data.get("mqtt_topic")
-                    existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
-                    # MQTT power fields
-                    existing.mqtt_power_topic = plug_data.get("mqtt_power_topic")
-                    existing.mqtt_power_path = plug_data.get("mqtt_power_path")
-                    existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0)
-                    # MQTT energy fields
-                    existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic")
-                    existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
-                    existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0)
-                    # MQTT state fields
-                    existing.mqtt_state_topic = plug_data.get("mqtt_state_topic")
-                    existing.mqtt_state_path = plug_data.get("mqtt_state_path")
-                    existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value")
-                    existing.printer_id = printer_id
-                    existing.enabled = plug_data.get("enabled", True)
-                    existing.auto_on = plug_data.get("auto_on", True)
-                    existing.auto_off = plug_data.get("auto_off", True)
-                    existing.off_delay_mode = plug_data.get("off_delay_mode", "time")
-                    existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5)
-                    existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70)
-                    existing.username = plug_data.get("username")
-                    existing.password = plug_data.get("password")
-                    existing.power_alert_enabled = plug_data.get("power_alert_enabled", False)
-                    existing.power_alert_high = plug_data.get("power_alert_high")
-                    existing.power_alert_low = plug_data.get("power_alert_low")
-                    existing.schedule_enabled = plug_data.get("schedule_enabled", False)
-                    existing.schedule_on_time = plug_data.get("schedule_on_time")
-                    existing.schedule_off_time = plug_data.get("schedule_off_time")
-                    existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
-                    existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
-                    restored["smart_plugs"] += 1
-                else:
-                    skipped["smart_plugs"] += 1
-                    skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_identifier})")
-            else:
-                plug = SmartPlug(
-                    name=plug_data["name"],
-                    plug_type=plug_type,
-                    ip_address=plug_data.get("ip_address"),
-                    ha_entity_id=plug_data.get("ha_entity_id"),
-                    ha_power_entity=plug_data.get("ha_power_entity"),
-                    ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
-                    ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
-                    # MQTT fields (legacy)
-                    mqtt_topic=plug_data.get("mqtt_topic"),
-                    mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
-                    # MQTT power fields
-                    mqtt_power_topic=plug_data.get("mqtt_power_topic"),
-                    mqtt_power_path=plug_data.get("mqtt_power_path"),
-                    mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0),
-                    # MQTT energy fields
-                    mqtt_energy_topic=plug_data.get("mqtt_energy_topic"),
-                    mqtt_energy_path=plug_data.get("mqtt_energy_path"),
-                    mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0),
-                    # MQTT state fields
-                    mqtt_state_topic=plug_data.get("mqtt_state_topic"),
-                    mqtt_state_path=plug_data.get("mqtt_state_path"),
-                    mqtt_state_on_value=plug_data.get("mqtt_state_on_value"),
-                    printer_id=printer_id,
-                    enabled=plug_data.get("enabled", True),
-                    auto_on=plug_data.get("auto_on", True),
-                    auto_off=plug_data.get("auto_off", True),
-                    off_delay_mode=plug_data.get("off_delay_mode", "time"),
-                    off_delay_minutes=plug_data.get("off_delay_minutes", 5),
-                    off_temp_threshold=plug_data.get("off_temp_threshold", 70),
-                    username=plug_data.get("username"),
-                    password=plug_data.get("password"),
-                    power_alert_enabled=plug_data.get("power_alert_enabled", False),
-                    power_alert_high=plug_data.get("power_alert_high"),
-                    power_alert_low=plug_data.get("power_alert_low"),
-                    schedule_enabled=plug_data.get("schedule_enabled", False),
-                    schedule_on_time=plug_data.get("schedule_on_time"),
-                    schedule_off_time=plug_data.get("schedule_off_time"),
-                    show_in_switchbar=plug_data.get("show_in_switchbar", False),
-                    show_on_printer_card=plug_data.get("show_on_printer_card", True),
-                )
-                db.add(plug)
-                restored["smart_plugs"] += 1
-
-    # Restore external links (skip or overwrite duplicates by name+url)
-    if "external_links" in backup:
-        icons_dir = base_dir / "icons"
-        icons_dir.mkdir(parents=True, exist_ok=True)
-
-        for link_data in backup["external_links"]:
-            result = await db.execute(
-                select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"])
-            )
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    existing.icon = link_data.get("icon", "link")
-                    existing.sort_order = link_data.get("sort_order", 0)
-                    # Handle custom icon
-                    if link_data.get("custom_icon"):
-                        existing.custom_icon = link_data["custom_icon"]
-                    restored["external_links"] += 1
-                else:
-                    skipped["external_links"] += 1
-                    skipped_details["external_links"].append(link_data["name"])
-            else:
-                link = ExternalLink(
-                    name=link_data["name"],
-                    url=link_data["url"],
-                    icon=link_data.get("icon", "link"),
-                    custom_icon=link_data.get("custom_icon"),
-                    sort_order=link_data.get("sort_order", 0),
-                )
-                db.add(link)
-                restored["external_links"] += 1
-
-    # Restore filaments (skip or overwrite duplicates by name+type+brand)
-    if "filaments" in backup:
-        for filament_data in backup["filaments"]:
-            result = await db.execute(
-                select(Filament).where(
-                    Filament.name == filament_data["name"],
-                    Filament.type == filament_data["type"],
-                    Filament.brand == filament_data.get("brand"),
-                )
-            )
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    existing.color = filament_data.get("color")
-                    existing.color_hex = filament_data.get("color_hex")
-                    existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0)
-                    existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0)
-                    existing.currency = filament_data.get("currency", "USD")
-                    existing.density = filament_data.get("density")
-                    existing.print_temp_min = filament_data.get("print_temp_min")
-                    existing.print_temp_max = filament_data.get("print_temp_max")
-                    existing.bed_temp_min = filament_data.get("bed_temp_min")
-                    existing.bed_temp_max = filament_data.get("bed_temp_max")
-                    restored["filaments"] += 1
-                else:
-                    skipped["filaments"] += 1
-                    skipped_details["filaments"].append(
-                        f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})"
-                    )
-            else:
-                filament = Filament(
-                    name=filament_data["name"],
-                    type=filament_data["type"],
-                    brand=filament_data.get("brand"),
-                    color=filament_data.get("color"),
-                    color_hex=filament_data.get("color_hex"),
-                    cost_per_kg=filament_data.get("cost_per_kg", 25.0),
-                    spool_weight_g=filament_data.get("spool_weight_g", 1000.0),
-                    currency=filament_data.get("currency", "USD"),
-                    density=filament_data.get("density"),
-                    print_temp_min=filament_data.get("print_temp_min"),
-                    print_temp_max=filament_data.get("print_temp_max"),
-                    bed_temp_min=filament_data.get("bed_temp_min"),
-                    bed_temp_max=filament_data.get("bed_temp_max"),
-                )
-                db.add(filament)
-                restored["filaments"] += 1
-
-    # Restore maintenance types (skip or overwrite duplicates by name)
-    if "maintenance_types" in backup:
-        for mt_data in backup["maintenance_types"]:
-            result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    existing.description = mt_data.get("description")
-                    existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0)
-                    existing.interval_type = mt_data.get("interval_type", "hours")
-                    existing.icon = mt_data.get("icon")
-                    # Don't overwrite is_system
-                    restored["maintenance_types"] += 1
-                else:
-                    skipped["maintenance_types"] += 1
-                    skipped_details["maintenance_types"].append(mt_data["name"])
-            else:
-                mt = MaintenanceType(
-                    name=mt_data["name"],
-                    description=mt_data.get("description"),
-                    default_interval_hours=mt_data.get("default_interval_hours", 100.0),
-                    interval_type=mt_data.get("interval_type", "hours"),
-                    icon=mt_data.get("icon"),
-                    is_system=mt_data.get("is_system", False),
-                )
-                db.add(mt)
-                restored["maintenance_types"] += 1
-
-    # Restore printer maintenance settings (per-printer)
-    if "printer_maintenance" in backup:
-        # Build lookups
-        printer_serial_to_id: dict[str, int] = {}
-        maint_type_name_to_id: dict[str, int] = {}
-
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        mt_result = await db.execute(select(MaintenanceType))
-        for mt in mt_result.scalars().all():
-            maint_type_name_to_id[mt.name] = mt.id
-
-        restored["printer_maintenance"] = 0
-        skipped["printer_maintenance"] = 0
-        skipped_details["printer_maintenance"] = []
-
-        for pm_data in backup["printer_maintenance"]:
-            printer_serial = pm_data.get("printer_serial")
-            maint_type_name = pm_data.get("maintenance_type_name")
-
-            if not printer_serial or not maint_type_name:
-                continue
-
-            printer_id = printer_serial_to_id.get(printer_serial)
-            maint_type_id = maint_type_name_to_id.get(maint_type_name)
-
-            if not printer_id or not maint_type_id:
-                skipped["printer_maintenance"] += 1
-                skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
-                continue
-
-            # Check if exists
-            result = await db.execute(
-                select(PrinterMaintenance).where(
-                    PrinterMaintenance.printer_id == printer_id,
-                    PrinterMaintenance.maintenance_type_id == maint_type_id,
-                )
-            )
-            existing = result.scalar_one_or_none()
-
-            if existing:
-                if overwrite:
-                    existing.custom_interval_hours = pm_data.get("custom_interval_hours")
-                    existing.custom_interval_type = pm_data.get("custom_interval_type")
-                    existing.enabled = pm_data.get("enabled", True)
-                    existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0)
-                    if pm_data.get("last_performed_at"):
-                        existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
-                    restored["printer_maintenance"] += 1
-                else:
-                    skipped["printer_maintenance"] += 1
-                    skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}")
-            else:
-                pm = PrinterMaintenance(
-                    printer_id=printer_id,
-                    maintenance_type_id=maint_type_id,
-                    custom_interval_hours=pm_data.get("custom_interval_hours"),
-                    custom_interval_type=pm_data.get("custom_interval_type"),
-                    enabled=pm_data.get("enabled", True),
-                    last_performed_hours=pm_data.get("last_performed_hours", 0.0),
-                )
-                if pm_data.get("last_performed_at"):
-                    pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"])
-                db.add(pm)
-                restored["printer_maintenance"] += 1
-
-    # Restore maintenance history
-    if "maintenance_history" in backup:
-        # Build lookups
-        printer_serial_to_id: dict[str, int] = {}
-        maint_type_name_to_id: dict[str, int] = {}
-
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        mt_result = await db.execute(select(MaintenanceType))
-        for mt in mt_result.scalars().all():
-            maint_type_name_to_id[mt.name] = mt.id
-
-        restored["maintenance_history"] = 0
-        skipped["maintenance_history"] = 0
-        skipped_details["maintenance_history"] = []
-
-        for mh_data in backup["maintenance_history"]:
-            printer_serial = mh_data.get("printer_serial")
-            maint_type_name = mh_data.get("maintenance_type_name")
-
-            if not printer_serial or not maint_type_name:
-                continue
-
-            printer_id = printer_serial_to_id.get(printer_serial)
-            maint_type_id = maint_type_name_to_id.get(maint_type_name)
-
-            if not printer_id or not maint_type_id:
-                skipped["maintenance_history"] += 1
-                continue
-
-            # Find the PrinterMaintenance record
-            result = await db.execute(
-                select(PrinterMaintenance).where(
-                    PrinterMaintenance.printer_id == printer_id,
-                    PrinterMaintenance.maintenance_type_id == maint_type_id,
-                )
-            )
-            pm = result.scalar_one_or_none()
-
-            if not pm:
-                skipped["maintenance_history"] += 1
-                continue
-
-            # Create history entry (no duplicate check - history is append-only)
-            mh = MaintenanceHistory(
-                printer_maintenance_id=pm.id,
-                hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0),
-                notes=mh_data.get("notes"),
-            )
-            if mh_data.get("performed_at"):
-                mh.performed_at = datetime.fromisoformat(mh_data["performed_at"])
-            db.add(mh)
-            restored["maintenance_history"] += 1
-
-    # Restore archives (skip duplicates by content_hash - overwrite not supported for archives)
-    if "archives" in backup:
-        # Build printer serial to ID mapping
-        printer_serial_to_id: dict[str, int] = {}
-        printer_result = await db.execute(select(Printer))
-        for pr in printer_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        for archive_data in backup["archives"]:
-            # Skip if no content_hash or already exists
-            content_hash = archive_data.get("content_hash")
-            if content_hash:
-                result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
-                existing = result.scalar_one_or_none()
-                if existing:
-                    skipped["archives"] += 1
-                    skipped_details["archives"].append(archive_data.get("filename", "Unknown"))
-                    continue
-
-            # Only restore if file exists (from ZIP extraction)
-            file_path = archive_data.get("file_path")
-            if file_path and (base_dir / file_path).exists():
-                # Look up printer_id from serial
-                printer_serial = archive_data.get("printer_serial")
-                printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
-
-                archive = PrintArchive(
-                    filename=archive_data["filename"],
-                    file_path=file_path,
-                    file_size=archive_data.get("file_size", 0),
-                    content_hash=content_hash,
-                    printer_id=printer_id,
-                    thumbnail_path=archive_data.get("thumbnail_path"),
-                    timelapse_path=archive_data.get("timelapse_path"),
-                    source_3mf_path=archive_data.get("source_3mf_path"),
-                    f3d_path=archive_data.get("f3d_path"),
-                    print_name=archive_data.get("print_name"),
-                    print_time_seconds=archive_data.get("print_time_seconds"),
-                    filament_used_grams=archive_data.get("filament_used_grams"),
-                    filament_type=archive_data.get("filament_type"),
-                    filament_color=archive_data.get("filament_color"),
-                    layer_height=archive_data.get("layer_height"),
-                    total_layers=archive_data.get("total_layers"),
-                    nozzle_diameter=archive_data.get("nozzle_diameter"),
-                    bed_temperature=archive_data.get("bed_temperature"),
-                    nozzle_temperature=archive_data.get("nozzle_temperature"),
-                    status=archive_data.get("status", "completed"),
-                    makerworld_url=archive_data.get("makerworld_url"),
-                    designer=archive_data.get("designer"),
-                    external_url=archive_data.get("external_url"),
-                    is_favorite=archive_data.get("is_favorite", False),
-                    tags=archive_data.get("tags"),
-                    notes=archive_data.get("notes"),
-                    cost=archive_data.get("cost"),
-                    failure_reason=archive_data.get("failure_reason"),
-                    quantity=archive_data.get("quantity", 1),
-                    energy_kwh=archive_data.get("energy_kwh"),
-                    energy_cost=archive_data.get("energy_cost"),
-                    extra_data=archive_data.get("extra_data"),
-                    photos=archive_data.get("photos"),
-                )
-                db.add(archive)
-                restored["archives"] = restored.get("archives", 0) + 1
-
-    # Restore projects (skip or overwrite duplicates by name)
-    if "projects" in backup:
-        for project_data in backup["projects"]:
-            result = await db.execute(select(Project).where(Project.name == project_data["name"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    # Update existing project
-                    existing.description = project_data.get("description")
-                    existing.color = project_data.get("color")
-                    existing.status = project_data.get("status", "active")
-                    existing.target_count = project_data.get("target_count")
-                    existing.notes = project_data.get("notes")
-                    existing.tags = project_data.get("tags")
-                    existing.priority = project_data.get("priority", "normal")
-                    existing.budget = project_data.get("budget")
-                    existing.is_template = project_data.get("is_template", False)
-                    existing.template_source_id = project_data.get("template_source_id")
-                    existing.parent_id = project_data.get("parent_id")
-                    existing.attachments = project_data.get("attachments")
-                    if project_data.get("due_date"):
-                        existing.due_date = datetime.fromisoformat(project_data["due_date"])
-
-                    # Delete existing BOM items and re-add
-                    await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id))
-                    for bom_data in project_data.get("bom_items", []):
-                        bom_item = ProjectBOMItem(
-                            project_id=existing.id,
-                            name=bom_data["name"],
-                            quantity_needed=bom_data.get("quantity_needed", 1),
-                            quantity_acquired=bom_data.get("quantity_acquired", 0),
-                            unit_price=bom_data.get("unit_price"),
-                            sourcing_url=bom_data.get("sourcing_url"),
-                            stl_filename=bom_data.get("stl_filename"),
-                            remarks=bom_data.get("remarks"),
-                            sort_order=bom_data.get("sort_order", 0),
-                        )
-                        db.add(bom_item)
-
-                    restored["projects"] += 1
-                else:
-                    skipped["projects"] += 1
-                    skipped_details["projects"].append(project_data["name"])
-            else:
-                # Create new project
-                project = Project(
-                    name=project_data["name"],
-                    description=project_data.get("description"),
-                    color=project_data.get("color"),
-                    status=project_data.get("status", "active"),
-                    target_count=project_data.get("target_count"),
-                    notes=project_data.get("notes"),
-                    tags=project_data.get("tags"),
-                    priority=project_data.get("priority", "normal"),
-                    budget=project_data.get("budget"),
-                    is_template=project_data.get("is_template", False),
-                    template_source_id=project_data.get("template_source_id"),
-                    parent_id=project_data.get("parent_id"),
-                    attachments=project_data.get("attachments"),
-                )
-                if project_data.get("due_date"):
-                    project.due_date = datetime.fromisoformat(project_data["due_date"])
-
-                db.add(project)
-                await db.flush()  # Get the project ID
-
-                # Add BOM items
-                for bom_data in project_data.get("bom_items", []):
-                    bom_item = ProjectBOMItem(
-                        project_id=project.id,
-                        name=bom_data["name"],
-                        quantity_needed=bom_data.get("quantity_needed", 1),
-                        quantity_acquired=bom_data.get("quantity_acquired", 0),
-                        unit_price=bom_data.get("unit_price"),
-                        sourcing_url=bom_data.get("sourcing_url"),
-                        stl_filename=bom_data.get("stl_filename"),
-                        remarks=bom_data.get("remarks"),
-                        sort_order=bom_data.get("sort_order", 0),
-                    )
-                    db.add(bom_item)
-
-                restored["projects"] += 1
-
-    # Link archives to projects by name (after both are restored)
-    if "archives" in backup and "projects" in backup:
-        # Build project name to ID mapping
-        proj_result = await db.execute(select(Project))
-        project_name_to_id: dict[str, int] = {}
-        for proj in proj_result.scalars().all():
-            project_name_to_id[proj.name] = proj.id
-
-        # Update archives with project_id
-        for archive_data in backup["archives"]:
-            project_name = archive_data.get("project_name")
-            if project_name and project_name in project_name_to_id:
-                content_hash = archive_data.get("content_hash")
-                if content_hash:
-                    result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash))
-                    archive = result.scalar_one_or_none()
-                    if archive:
-                        archive.project_id = project_name_to_id[project_name]
-
-    # Restore print queue (must be after archives and projects)
-    if "print_queue" in backup:
-        # Build lookups
-        printer_serial_to_id: dict[str, int] = {}
-        archive_hash_to_id: dict[str, int] = {}
-        project_name_to_id: dict[str, int] = {}
-
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        ar_result = await db.execute(select(PrintArchive))
-        for ar in ar_result.scalars().all():
-            if ar.content_hash:
-                archive_hash_to_id[ar.content_hash] = ar.id
-
-        proj_result = await db.execute(select(Project))
-        for proj in proj_result.scalars().all():
-            project_name_to_id[proj.name] = proj.id
-
-        restored["print_queue"] = 0
-        skipped["print_queue"] = 0
-        skipped_details["print_queue"] = []
-
-        for qi_data in backup["print_queue"]:
-            printer_serial = qi_data.get("printer_serial")  # Can be None for unassigned items
-            archive_hash = qi_data.get("archive_hash")
-
-            # Archive is required, but printer can be None (unassigned)
-            if not archive_hash:
-                skipped["print_queue"] += 1
-                continue
-
-            # Look up printer_id (None if unassigned or printer not found)
-            printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None
-            archive_id = archive_hash_to_id.get(archive_hash)
-
-            # Archive must exist, but printer is optional (unassigned items)
-            if not archive_id:
-                skipped["print_queue"] += 1
-                skipped_details["print_queue"].append(
-                    f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}"
-                )
-                continue
-
-            # If printer_serial was specified but printer not found, skip
-            if printer_serial and not printer_id:
-                skipped["print_queue"] += 1
-                skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}")
-                continue
-
-            project_name = qi_data.get("project_name")
-            project_id = project_name_to_id.get(project_name) if project_name else None
-
-            qi = PrintQueueItem(
-                printer_id=printer_id,  # Can be None for unassigned items
-                archive_id=archive_id,
-                project_id=project_id,
-                position=qi_data.get("position", 0),
-                require_previous_success=qi_data.get("require_previous_success", False),
-                auto_off_after=qi_data.get("auto_off_after", False),
-                manual_start=qi_data.get("manual_start", False),
-                ams_mapping=qi_data.get("ams_mapping"),
-                plate_id=qi_data.get("plate_id"),
-                bed_levelling=qi_data.get("bed_levelling", True),
-                flow_cali=qi_data.get("flow_cali", False),
-                vibration_cali=qi_data.get("vibration_cali", True),
-                layer_inspect=qi_data.get("layer_inspect", False),
-                timelapse=qi_data.get("timelapse", False),
-                use_ams=qi_data.get("use_ams", True),
-                status=qi_data.get("status", "pending"),
-                error_message=qi_data.get("error_message"),
-            )
-            if qi_data.get("scheduled_time"):
-                qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"])
-            if qi_data.get("started_at"):
-                qi.started_at = datetime.fromisoformat(qi_data["started_at"])
-            if qi_data.get("completed_at"):
-                qi.completed_at = datetime.fromisoformat(qi_data["completed_at"])
-            db.add(qi)
-            restored["print_queue"] += 1
-
-    # Restore pending uploads (skip duplicates by filename)
-    if "pending_uploads" in backup:
-        # Ensure the pending uploads directory exists
-        pending_uploads_dir = base_dir / "virtual_printer" / "uploads"
-        pending_uploads_dir.mkdir(parents=True, exist_ok=True)
-
-        for upload_data in backup["pending_uploads"]:
-            # Check for existing by filename
-            result = await db.execute(
-                select(PendingUpload).where(
-                    PendingUpload.filename == upload_data["filename"],
-                    PendingUpload.status == "pending",
-                )
-            )
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    # Update existing
-                    existing.file_size = upload_data.get("file_size", 0)
-                    existing.source_ip = upload_data.get("source_ip")
-                    existing.tags = upload_data.get("tags")
-                    existing.notes = upload_data.get("notes")
-                    existing.project_id = upload_data.get("project_id")
-                    # Update file path if file was restored from ZIP
-                    if upload_data.get("file_path"):
-                        restored_file = base_dir / upload_data["file_path"]
-                        if restored_file.exists():
-                            # Move to proper location
-                            target_path = pending_uploads_dir / upload_data["filename"]
-                            if restored_file != target_path:
-                                import shutil
-
-                                shutil.move(str(restored_file), str(target_path))
-                            existing.file_path = str(target_path)
-                    restored["pending_uploads"] += 1
-                else:
-                    skipped["pending_uploads"] += 1
-                    skipped_details["pending_uploads"].append(upload_data["filename"])
-            else:
-                # Determine file path
-                file_path_str = None
-                if upload_data.get("file_path"):
-                    restored_file = base_dir / upload_data["file_path"]
-                    if restored_file.exists():
-                        # Move to proper location
-                        target_path = pending_uploads_dir / upload_data["filename"]
-                        if restored_file != target_path:
-                            import shutil
-
-                            shutil.move(str(restored_file), str(target_path))
-                        file_path_str = str(target_path)
-
-                # Parse uploaded_at
-                uploaded_at = None
-                if upload_data.get("uploaded_at"):
-                    try:
-                        uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00"))
-                    except (ValueError, AttributeError):
-                        uploaded_at = datetime.utcnow()
-                else:
-                    uploaded_at = datetime.utcnow()
-
-                pending = PendingUpload(
-                    filename=upload_data["filename"],
-                    file_path=file_path_str or "",
-                    file_size=upload_data.get("file_size", 0),
-                    source_ip=upload_data.get("source_ip"),
-                    status="pending",
-                    tags=upload_data.get("tags"),
-                    notes=upload_data.get("notes"),
-                    project_id=upload_data.get("project_id"),
-                    uploaded_at=uploaded_at,
-                )
-                db.add(pending)
-                restored["pending_uploads"] += 1
-
-    # Restore API keys (generates new keys since we can't restore the hash)
-    new_api_keys: list[dict] = []  # Track newly generated keys for response
-    if "api_keys" in backup:
-        from backend.app.core.auth import generate_api_key
-
-        # Build printer serial to ID mapping
-        printer_serial_to_id: dict[str, int] = {}
-        pr_result = await db.execute(select(Printer))
-        for pr in pr_result.scalars().all():
-            printer_serial_to_id[pr.serial_number] = pr.id
-
-        restored["api_keys"] = 0
-        skipped["api_keys"] = 0
-        skipped_details["api_keys"] = []
-
-        for key_data in backup["api_keys"]:
-            # Check if key with same name already exists
-            result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    # Update permissions but keep the existing key
-                    existing.can_queue = key_data.get("can_queue", True)
-                    existing.can_control_printer = key_data.get("can_control_printer", False)
-                    existing.can_read_status = key_data.get("can_read_status", True)
-                    existing.enabled = key_data.get("enabled", True)
-                    if key_data.get("expires_at"):
-                        existing.expires_at = datetime.fromisoformat(key_data["expires_at"])
-                    # Convert printer serials to IDs
-                    if key_data.get("printer_serials"):
-                        existing.printer_ids = [
-                            printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
-                        ]
-                    restored["api_keys"] += 1
-                else:
-                    skipped["api_keys"] += 1
-                    skipped_details["api_keys"].append(key_data["name"])
-            else:
-                # Generate new key
-                full_key, key_hash, key_prefix = generate_api_key()
-
-                # Convert printer serials to IDs
-                printer_ids = None
-                if key_data.get("printer_serials"):
-                    printer_ids = [
-                        printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id
-                    ]
-
-                api_key = APIKey(
-                    name=key_data["name"],
-                    key_hash=key_hash,
-                    key_prefix=key_prefix,
-                    can_queue=key_data.get("can_queue", True),
-                    can_control_printer=key_data.get("can_control_printer", False),
-                    can_read_status=key_data.get("can_read_status", True),
-                    printer_ids=printer_ids,
-                    enabled=key_data.get("enabled", True),
-                )
-                if key_data.get("expires_at"):
-                    api_key.expires_at = datetime.fromisoformat(key_data["expires_at"])
-                db.add(api_key)
-                restored["api_keys"] += 1
-
-                # Track the new key so user can see it
-                new_api_keys.append(
-                    {
-                        "name": key_data["name"],
-                        "key": full_key,
-                        "key_prefix": key_prefix,
-                    }
-                )
-
-    # Restore groups (before users, so groups exist for assignment)
-    if "groups" in backup:
-        for group_data in backup["groups"]:
-            result = await db.execute(select(Group).where(Group.name == group_data["name"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite and not existing.is_system:
-                    # Update non-system groups
-                    existing.description = group_data.get("description")
-                    existing.permissions = group_data.get("permissions", [])
-                    restored["groups"] += 1
-                else:
-                    skipped["groups"] += 1
-                    skipped_details["groups"].append(group_data["name"])
-            else:
-                group = Group(
-                    name=group_data["name"],
-                    description=group_data.get("description"),
-                    permissions=group_data.get("permissions", []),
-                    is_system=group_data.get("is_system", False),
-                )
-                db.add(group)
-                restored["groups"] += 1
-
-    # Flush to ensure groups are persisted before user assignment
-    await db.flush()
-
-    # Build group name to object lookup for user assignment
-    group_name_to_obj: dict[str, Group] = {}
-    result = await db.execute(select(Group))
-    for g in result.scalars().all():
-        group_name_to_obj[g.name] = g
-
-    # Restore users (note: passwords not included in backup - users will need new passwords)
-    # Users are skipped by default since they have no passwords; admin must recreate them
-    new_users: list[str] = []
-    if "users" in backup:
-        from backend.app.core.auth import get_password_hash
-
-        for user_data in backup["users"]:
-            result = await db.execute(select(User).where(User.username == user_data["username"]))
-            existing = result.scalar_one_or_none()
-            if existing:
-                if overwrite:
-                    existing.role = user_data.get("role", "user")
-                    existing.is_active = user_data.get("is_active", True)
-                    # Assign groups if provided
-                    group_names = user_data.get("groups", [])
-                    if group_names:
-                        existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
-                    # Don't change password - keep existing
-                    restored["users"] += 1
-                else:
-                    skipped["users"] += 1
-                    skipped_details["users"].append(user_data["username"])
-            else:
-                # Create user with a temporary password that must be changed
-                # Generate a random temporary password
-                import secrets
-
-                temp_password = secrets.token_urlsafe(16)
-                user = User(
-                    username=user_data["username"],
-                    password_hash=get_password_hash(temp_password),
-                    role=user_data.get("role", "user"),
-                    is_active=user_data.get("is_active", True),
-                )
-                # Assign groups if provided
-                group_names = user_data.get("groups", [])
-                if group_names:
-                    user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
-                db.add(user)
-                restored["users"] += 1
-                new_users.append(f"{user_data['username']} (temp password: {temp_password})")
-
-    # Restore GitHub backup configuration (note: access_token not included for security)
-    if "github_backup" in backup:
-        github_data = backup["github_backup"]
-        result = await db.execute(select(GitHubBackupConfig).limit(1))
-        existing = result.scalar_one_or_none()
-        if existing:
-            if overwrite:
-                existing.repository_url = github_data.get("repository_url", existing.repository_url)
-                existing.branch = github_data.get("branch", existing.branch)
-                existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled)
-                existing.schedule_type = github_data.get("schedule_type", existing.schedule_type)
-                existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles)
-                existing.backup_cloud_profiles = github_data.get(
-                    "backup_cloud_profiles", existing.backup_cloud_profiles
-                )
-                existing.backup_settings = github_data.get("backup_settings", existing.backup_settings)
-                existing.enabled = github_data.get("enabled", existing.enabled)
-                # Note: access_token must be re-entered after restore
-                restored["github_backup"] += 1
-            else:
-                skipped["github_backup"] += 1
-        else:
-            config = GitHubBackupConfig(
-                repository_url=github_data.get("repository_url", ""),
-                access_token="",  # Must be entered after restore
-                branch=github_data.get("branch", "main"),
-                schedule_enabled=github_data.get("schedule_enabled", False),
-                schedule_type=github_data.get("schedule_type", "daily"),
-                backup_kprofiles=github_data.get("backup_kprofiles", True),
-                backup_cloud_profiles=github_data.get("backup_cloud_profiles", True),
-                backup_settings=github_data.get("backup_settings", False),
-                enabled=False,  # Disabled until token is entered
-            )
-            db.add(config)
-            restored["github_backup"] += 1
+    This is a simplified restore that replaces the database and all data directories
+    from the backup ZIP. Requires a restart after restore.
+    """
+    import shutil
+    import tempfile
 
 
-    await db.commit()
-
-    # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
-    # This ensures connections are re-established after restore, even if printers were skipped
-    if "printers" in backup:
-        # Need fresh query after commit to get proper IDs for newly created printers
-        result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-        active_printers = result.scalars().all()
-        for printer in active_printers:
-            # This will disconnect existing connection (if any) and reconnect
-            try:
-                await printer_manager.connect_printer(printer)
-            except Exception:
-                pass  # Connection failed, but don't fail the restore
-
-    # If settings were restored, check if Spoolman needs to be reconnected
-    if "settings" in backup:
-        spoolman_enabled = await get_setting(db, "spoolman_enabled")
-        spoolman_url = await get_setting(db, "spoolman_url")
-        if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url:
-            try:
-                client = await init_spoolman_client(spoolman_url)
-                if await client.health_check():
-                    pass  # Connected successfully
-            except Exception:
-                pass  # Spoolman connection failed, but don't fail the restore
-
-        # Reconfigure virtual printer if settings were restored
-        try:
-            from backend.app.services.virtual_printer import virtual_printer_manager
-
-            vp_enabled = await get_setting(db, "virtual_printer_enabled")
-            vp_access_code = await get_setting(db, "virtual_printer_access_code")
-            vp_mode = await get_setting(db, "virtual_printer_mode")
-            vp_model = await get_setting(db, "virtual_printer_model")
-
-            enabled = vp_enabled and vp_enabled.lower() == "true"
-            access_code = vp_access_code or ""
-            mode = vp_mode or "immediate"
-            model = vp_model or ""
-
-            if enabled and access_code:
-                await virtual_printer_manager.configure(
-                    enabled=True,
-                    access_code=access_code,
-                    mode=mode,
-                    model=model,
-                )
-            elif not enabled and virtual_printer_manager.is_enabled:
-                await virtual_printer_manager.configure(
-                    enabled=False,
-                    access_code=access_code,
-                    mode=mode,
-                    model=model,
-                )
-        except Exception:
-            pass  # Virtual printer config failed, but don't fail the restore
+    from fastapi import HTTPException
 
 
-        # Reconfigure MQTT relay if settings were restored
-        try:
-            from backend.app.services.mqtt_relay import mqtt_relay
+    from backend.app.core.database import close_all_connections
 
 
-            mqtt_settings = {
-                "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true",
-                "mqtt_broker": await get_setting(db, "mqtt_broker") or "",
-                "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
-                "mqtt_username": await get_setting(db, "mqtt_username") or "",
-                "mqtt_password": await get_setting(db, "mqtt_password") or "",
-                "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy",
-                "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
-            }
-            await mqtt_relay.configure(mqtt_settings)
-        except Exception:
-            pass  # MQTT relay config failed, but don't fail the restore
-
-    # Build summary message
-    restored_parts = []
-    for key, count in restored.items():
-        if count > 0:
-            restored_parts.append(f"{count} {key.replace('_', ' ')}")
-
-    if files_restored > 0:
-        restored_parts.append(f"{files_restored} files")
-
-    skipped_parts = []
-    total_skipped = sum(skipped.values())
-    for key, count in skipped.items():
-        if count > 0:
-            skipped_parts.append(f"{count} {key.replace('_', ' ')}")
-
-    message_parts = []
-    if restored_parts:
-        message_parts.append(f"Restored: {', '.join(restored_parts)}")
-    if skipped_parts:
-        message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}")
-
-    response = {
-        "success": True,
-        "message": ". ".join(message_parts) if message_parts else "Nothing to restore",
-        "restored": restored,
-        "skipped": skipped,
-        "skipped_details": skipped_details,
-        "files_restored": files_restored,
-        "total_skipped": total_skipped,
-    }
-
-    # Include newly generated API keys if any (so user can see them)
-    if new_api_keys:
-        response["new_api_keys"] = new_api_keys
+    base_dir = app_settings.base_dir
+    db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
 
 
-    # Include newly created users with temp passwords (so admin can share them)
-    if new_users:
-        response["new_users"] = new_users
+    with tempfile.TemporaryDirectory() as temp_dir:
+        temp_path = Path(temp_dir)
 
 
-    return response
+        # 1. Read and extract ZIP
+        content = await file.read()
 
 
+        # Check if it's a valid ZIP
+        if not file.filename or not file.filename.endswith(".zip"):
+            raise HTTPException(400, "Invalid backup file: must be a .zip file")
 
 
-# =============================================================================
-# Virtual Printer Settings
-# =============================================================================
+        try:
+            with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
+                zf.extractall(temp_path)
+        except zipfile.BadZipFile:
+            raise HTTPException(400, "Invalid backup file: not a valid ZIP")
+
+        # 2. Validate backup (must have database)
+        backup_db = temp_path / "bambuddy.db"
+        if not backup_db.exists():
+            raise HTTPException(400, "Invalid backup: missing bambuddy.db")
+
+        # 3. Close current database connections
+        await close_all_connections()
+
+        # 4. Replace database
+        shutil.copy2(backup_db, db_path)
+
+        # 5. Replace data directories
+        dirs_to_restore = [
+            ("archive", base_dir / "archive"),
+            ("virtual_printer", base_dir / "virtual_printer"),
+            ("plate_calibration", app_settings.plate_calibration_dir),
+            ("icons", base_dir / "icons"),
+            ("projects", base_dir / "projects"),
+        ]
+
+        for name, dest_dir in dirs_to_restore:
+            src_dir = temp_path / name
+            if src_dir.exists():
+                if dest_dir.exists():
+                    shutil.rmtree(dest_dir)
+                shutil.copytree(src_dir, dest_dir)
+
+        # 6. Note: Database connection will be reinitialized on restart
+        # The application should be restarted after restore
+
+        return {
+            "success": True,
+            "message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.",
+        }
 
 
 
 
 @router.get("/virtual-printer/models")
 @router.get("/virtual-printer/models")

+ 4 - 0
backend/app/api/routes/support.py

@@ -410,6 +410,10 @@ def _sanitize_log_content(content: str) -> str:
     # Replace email addresses
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
 
+    # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
+    # These appear in logs as [SERIAL] or in messages
+    content = re.sub(r"\b(0[0-3][A-Z0-9])[A-Z0-9]{9,13}\b", r"\1[SERIAL]", content)
+
     # Replace paths with usernames
     # Replace paths with usernames
     content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
     content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
     content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
     content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)

+ 64 - 3
backend/app/api/routes/users.py

@@ -1,5 +1,5 @@
-from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy import select
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -11,7 +11,10 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.models.archive import PrintArchive
 from backend.app.models.group import Group
 from backend.app.models.group import Group
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 
 
@@ -198,13 +201,55 @@ async def update_user(
     return _user_to_response(user)
     return _user_to_response(user)
 
 
 
 
+@router.get("/{user_id}/items-count")
+async def get_user_items_count(
+    user_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get count of items created by this user."""
+    # Verify user exists
+    result = await db.execute(select(User).where(User.id == user_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Count archives
+    archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))
+    archives_count = archives_result.scalar() or 0
+
+    # Count queue items
+    queue_result = await db.execute(
+        select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)
+    )
+    queue_items_count = queue_result.scalar() or 0
+
+    # Count library files
+    library_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.created_by_id == user_id))
+    library_files_count = library_result.scalar() or 0
+
+    return {
+        "archives": archives_count,
+        "queue_items": queue_items_count,
+        "library_files": library_files_count,
+    }
+
+
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_user(
 async def delete_user(
     user_id: int,
     user_id: int,
+    delete_items: bool = Query(False, description="Delete all items created by this user"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Delete a user."""
+    """Delete a user.
+
+    If delete_items=True, all archives, queue items, and library files created by
+    this user will also be deleted. Otherwise, these items will become "ownerless"
+    (created_by_id set to NULL by the foreign key constraint).
+    """
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
@@ -241,6 +286,22 @@ async def delete_user(
             detail="Cannot delete your own account",
             detail="Cannot delete your own account",
         )
         )
 
 
+    if delete_items:
+        # Delete all items created by this user
+        await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
+        await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
+        await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
+    else:
+        # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
+        # across different database backends, including SQLite without foreign key support)
+        from sqlalchemy import update
+
+        await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
+        await db.execute(
+            update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
+        )
+        await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
+
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
 
 

+ 76 - 0
backend/app/core/auth.py

@@ -470,3 +470,79 @@ def RequirePermission(*permissions: str | Permission):
 def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
 def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     """Convenience dependency that requires permissions if auth is enabled."""
     """Convenience dependency that requires permissions if auth is enabled."""
     return Depends(require_permission_if_auth_enabled(*permissions))
     return Depends(require_permission_if_auth_enabled(*permissions))
+
+
+def require_ownership_permission(
+    all_permission: str | Permission,
+    own_permission: str | Permission,
+):
+    """Dependency factory for ownership-based permission checks.
+
+    - User with `all_permission` can modify any item
+    - User with `own_permission` can only modify items where created_by_id == user.id
+    - Ownerless items (created_by_id = null) require `all_permission`
+
+    Returns:
+        A dependency function that returns (user, can_modify_all).
+        - can_modify_all=True: user can modify any item
+        - can_modify_all=False: user can only modify their own items
+    """
+    all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
+    own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
+
+    async def checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> tuple[User | None, bool]:
+        """Returns (user, can_modify_all).
+
+        - can_modify_all=True: user can modify any item
+        - can_modify_all=False: user can only modify their own items
+        """
+        async with async_session() as db:
+            auth_enabled = await is_auth_enabled(db)
+            if not auth_enabled:
+                return None, True  # Auth disabled, allow all
+
+            if credentials is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Authentication required",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            try:
+                token = credentials.credentials
+                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+            except JWTError:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            if user.has_permission(all_perm):
+                return user, True
+            if user.has_permission(own_perm):
+                return user, False
+
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"Missing permission: {own_perm} or {all_perm}",
+            )
+
+    return checker

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.6"
+APP_VERSION = "0.1.7b"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

+ 139 - 3
backend/app/core/database.py

@@ -15,6 +15,26 @@ async_session = async_sessionmaker(
 )
 )
 
 
 
 
+async def close_all_connections():
+    """Close all database connections for backup/restore operations."""
+    global engine
+    await engine.dispose()
+
+
+async def reinitialize_database():
+    """Reinitialize database connection after restore."""
+    global engine, async_session
+    engine = create_async_engine(
+        settings.database_url,
+        echo=settings.debug,
+    )
+    async_session = async_sessionmaker(
+        engine,
+        class_=AsyncSession,
+        expire_on_commit=False,
+    )
+
+
 class Base(DeclarativeBase):
 class Base(DeclarativeBase):
     pass
     pass
 
 
@@ -1000,6 +1020,60 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
+    try:
+        await conn.execute(
+            text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
+    try:
+        await conn.execute(
+            text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
+    try:
+        await conn.execute(
+            text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Convert absolute paths to relative paths in library_files table
+    # This ensures backup/restore portability across different installations
+    try:
+        base_dir_str = str(settings.base_dir)
+        # Ensure we have a trailing slash for clean replacement
+        if not base_dir_str.endswith("/"):
+            base_dir_str += "/"
+
+        # Update file_path - remove base_dir prefix from absolute paths
+        await conn.execute(
+            text("""
+            UPDATE library_files
+            SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)
+            WHERE file_path LIKE :pattern
+        """),
+            {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
+        )
+
+        # Update thumbnail_path - remove base_dir prefix from absolute paths
+        await conn.execute(
+            text("""
+            UPDATE library_files
+            SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)
+            WHERE thumbnail_path LIKE :pattern
+        """),
+            {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
+        )
+    except Exception:
+        pass
+
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""
@@ -1046,6 +1120,8 @@ async def seed_default_groups():
     don't exist, then migrates existing users:
     don't exist, then migrates existing users:
     - Users with role='admin' -> Administrators group
     - Users with role='admin' -> Administrators group
     - Users with role='user' -> Operators group
     - Users with role='user' -> Operators group
+
+    Also migrates old permissions to new ownership-based permissions (Issue #205).
     """
     """
     import logging
     import logging
 
 
@@ -1057,10 +1133,32 @@ async def seed_default_groups():
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
+    # Map old permissions to new ones for migration
+    # Administrators get *_all permissions, Operators get *_own permissions
+    PERMISSION_MIGRATION_ALL = {
+        "queue:update": "queue:update_all",
+        "queue:delete": "queue:delete_all",
+        "archives:update": "archives:update_all",
+        "archives:delete": "archives:delete_all",
+        "archives:reprint": "archives:reprint_all",
+        "library:update": "library:update_all",
+        "library:delete": "library:delete_all",
+    }
+
+    PERMISSION_MIGRATION_OWN = {
+        "queue:update": "queue:update_own",
+        "queue:delete": "queue:delete_own",
+        "archives:update": "archives:update_own",
+        "archives:delete": "archives:delete_own",
+        "archives:reprint": "archives:reprint_own",
+        "library:update": "library:update_own",
+        "library:delete": "library:delete_own",
+    }
+
     async with async_session() as session:
     async with async_session() as session:
         # Get existing groups
         # Get existing groups
-        result = await session.execute(select(Group.name))
-        existing_groups = {row[0] for row in result.fetchall()}
+        result = await session.execute(select(Group))
+        existing_groups = {group.name: group for group in result.scalars().all()}
 
 
         # Create default groups if they don't exist
         # Create default groups if they don't exist
         groups_created = []
         groups_created = []
@@ -1075,12 +1173,50 @@ async def seed_default_groups():
                 session.add(group)
                 session.add(group)
                 groups_created.append(group_name)
                 groups_created.append(group_name)
                 logger.info(f"Created default group: {group_name}")
                 logger.info(f"Created default group: {group_name}")
+            else:
+                # Migrate existing group's permissions from old to new format
+                group = existing_groups[group_name]
+                if group.permissions:
+                    updated = False
+                    new_permissions = list(group.permissions)
+
+                    # Determine which migration map to use based on group
+                    migration_map = (
+                        PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN
+                    )
+
+                    for old_perm, new_perm in migration_map.items():
+                        if old_perm in new_permissions:
+                            new_permissions.remove(old_perm)
+                            if new_perm not in new_permissions:
+                                new_permissions.append(new_perm)
+                            updated = True
+                            logger.info(f"Migrated permission '{old_perm}' to '{new_perm}' in group '{group_name}'")
+
+                    # For Administrators, also ensure they get *_all permissions if they have any new *_own
+                    if group_name == "Administrators":
+                        for _own_perm, all_perm in [
+                            ("queue:update_own", "queue:update_all"),
+                            ("queue:delete_own", "queue:delete_all"),
+                            ("archives:update_own", "archives:update_all"),
+                            ("archives:delete_own", "archives:delete_all"),
+                            ("archives:reprint_own", "archives:reprint_all"),
+                            ("library:update_own", "library:update_all"),
+                            ("library:delete_own", "library:delete_all"),
+                        ]:
+                            # Add *_all if not present
+                            if all_perm not in new_permissions:
+                                new_permissions.append(all_perm)
+                                updated = True
+
+                    if updated:
+                        group.permissions = new_permissions
 
 
         await session.commit()
         await session.commit()
 
 
         # Migrate existing users to groups if they're not already in any group
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
         if groups_created:
-            # Get the groups we need
+            # Refresh to get newly created groups
             admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
             admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
             admin_group = admin_result.scalar_one_or_none()
             admin_group = admin_result.scalar_one_or_none()
 
 

+ 41 - 24
backend/app/core/permissions.py

@@ -21,26 +21,34 @@ class Permission(str, Enum):
     PRINTERS_DELETE = "printers:delete"
     PRINTERS_DELETE = "printers:delete"
     PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
     PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
     PRINTERS_FILES = "printers:files"  # Send files to printer
     PRINTERS_FILES = "printers:files"  # Send files to printer
+    PRINTERS_AMS_RFID = "printers:ams_rfid"  # Re-read AMS RFID tags
 
 
     # Archives
     # Archives
     ARCHIVES_READ = "archives:read"
     ARCHIVES_READ = "archives:read"
     ARCHIVES_CREATE = "archives:create"
     ARCHIVES_CREATE = "archives:create"
-    ARCHIVES_UPDATE = "archives:update"
-    ARCHIVES_DELETE = "archives:delete"
-    ARCHIVES_REPRINT = "archives:reprint"  # Reprint from archive
+    ARCHIVES_UPDATE_OWN = "archives:update_own"
+    ARCHIVES_UPDATE_ALL = "archives:update_all"
+    ARCHIVES_DELETE_OWN = "archives:delete_own"
+    ARCHIVES_DELETE_ALL = "archives:delete_all"
+    ARCHIVES_REPRINT_OWN = "archives:reprint_own"
+    ARCHIVES_REPRINT_ALL = "archives:reprint_all"
 
 
     # Queue
     # Queue
     QUEUE_READ = "queue:read"
     QUEUE_READ = "queue:read"
     QUEUE_CREATE = "queue:create"
     QUEUE_CREATE = "queue:create"
-    QUEUE_UPDATE = "queue:update"
-    QUEUE_DELETE = "queue:delete"
+    QUEUE_UPDATE_OWN = "queue:update_own"
+    QUEUE_UPDATE_ALL = "queue:update_all"
+    QUEUE_DELETE_OWN = "queue:delete_own"
+    QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_REORDER = "queue:reorder"
     QUEUE_REORDER = "queue:reorder"
 
 
     # Library
     # Library
     LIBRARY_READ = "library:read"
     LIBRARY_READ = "library:read"
     LIBRARY_UPLOAD = "library:upload"
     LIBRARY_UPLOAD = "library:upload"
-    LIBRARY_UPDATE = "library:update"
-    LIBRARY_DELETE = "library:delete"
+    LIBRARY_UPDATE_OWN = "library:update_own"
+    LIBRARY_UPDATE_ALL = "library:update_all"
+    LIBRARY_DELETE_OWN = "library:delete_own"
+    LIBRARY_DELETE_ALL = "library:delete_all"
 
 
     # Projects
     # Projects
     PROJECTS_READ = "projects:read"
     PROJECTS_READ = "projects:read"
@@ -152,26 +160,34 @@ PERMISSION_CATEGORIES = {
         Permission.PRINTERS_DELETE,
         Permission.PRINTERS_DELETE,
         Permission.PRINTERS_CONTROL,
         Permission.PRINTERS_CONTROL,
         Permission.PRINTERS_FILES,
         Permission.PRINTERS_FILES,
+        Permission.PRINTERS_AMS_RFID,
     ],
     ],
     "Archives": [
     "Archives": [
         Permission.ARCHIVES_READ,
         Permission.ARCHIVES_READ,
         Permission.ARCHIVES_CREATE,
         Permission.ARCHIVES_CREATE,
-        Permission.ARCHIVES_UPDATE,
-        Permission.ARCHIVES_DELETE,
-        Permission.ARCHIVES_REPRINT,
+        Permission.ARCHIVES_UPDATE_OWN,
+        Permission.ARCHIVES_UPDATE_ALL,
+        Permission.ARCHIVES_DELETE_OWN,
+        Permission.ARCHIVES_DELETE_ALL,
+        Permission.ARCHIVES_REPRINT_OWN,
+        Permission.ARCHIVES_REPRINT_ALL,
     ],
     ],
     "Queue": [
     "Queue": [
         Permission.QUEUE_READ,
         Permission.QUEUE_READ,
         Permission.QUEUE_CREATE,
         Permission.QUEUE_CREATE,
-        Permission.QUEUE_UPDATE,
-        Permission.QUEUE_DELETE,
+        Permission.QUEUE_UPDATE_OWN,
+        Permission.QUEUE_UPDATE_ALL,
+        Permission.QUEUE_DELETE_OWN,
+        Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_REORDER,
         Permission.QUEUE_REORDER,
     ],
     ],
     "Library": [
     "Library": [
         Permission.LIBRARY_READ,
         Permission.LIBRARY_READ,
         Permission.LIBRARY_UPLOAD,
         Permission.LIBRARY_UPLOAD,
-        Permission.LIBRARY_UPDATE,
-        Permission.LIBRARY_DELETE,
+        Permission.LIBRARY_UPDATE_OWN,
+        Permission.LIBRARY_UPDATE_ALL,
+        Permission.LIBRARY_DELETE_OWN,
+        Permission.LIBRARY_DELETE_ALL,
     ],
     ],
     "Projects": [
     "Projects": [
         Permission.PROJECTS_READ,
         Permission.PROJECTS_READ,
@@ -291,23 +307,24 @@ DEFAULT_GROUPS = {
             Permission.PRINTERS_DELETE.value,
             Permission.PRINTERS_DELETE.value,
             Permission.PRINTERS_CONTROL.value,
             Permission.PRINTERS_CONTROL.value,
             Permission.PRINTERS_FILES.value,
             Permission.PRINTERS_FILES.value,
-            # Archives - full access
+            Permission.PRINTERS_AMS_RFID.value,
+            # Archives - own items only
             Permission.ARCHIVES_READ.value,
             Permission.ARCHIVES_READ.value,
             Permission.ARCHIVES_CREATE.value,
             Permission.ARCHIVES_CREATE.value,
-            Permission.ARCHIVES_UPDATE.value,
-            Permission.ARCHIVES_DELETE.value,
-            Permission.ARCHIVES_REPRINT.value,
-            # Queue - full access
+            Permission.ARCHIVES_UPDATE_OWN.value,
+            Permission.ARCHIVES_DELETE_OWN.value,
+            Permission.ARCHIVES_REPRINT_OWN.value,
+            # Queue - own items only
             Permission.QUEUE_READ.value,
             Permission.QUEUE_READ.value,
             Permission.QUEUE_CREATE.value,
             Permission.QUEUE_CREATE.value,
-            Permission.QUEUE_UPDATE.value,
-            Permission.QUEUE_DELETE.value,
+            Permission.QUEUE_UPDATE_OWN.value,
+            Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
             Permission.QUEUE_REORDER.value,
-            # Library - full access
+            # Library - own items only
             Permission.LIBRARY_READ.value,
             Permission.LIBRARY_READ.value,
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPLOAD.value,
-            Permission.LIBRARY_UPDATE.value,
-            Permission.LIBRARY_DELETE.value,
+            Permission.LIBRARY_UPDATE_OWN.value,
+            Permission.LIBRARY_DELETE_OWN.value,
             # Projects - full access
             # Projects - full access
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_CREATE.value,
             Permission.PROJECTS_CREATE.value,

+ 3 - 0
backend/app/main.py

@@ -1461,6 +1461,9 @@ async def on_print_complete(printer_id: int, data: dict):
     except Exception as e:
     except Exception as e:
         logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}")
         logger.warning(f"[CALLBACK] WebSocket send_print_complete failed: {e}")
 
 
+    # Clear current print user tracking (Issue #206)
+    printer_manager.clear_current_print_user(printer_id)
+
     # MQTT relay - publish print complete
     # MQTT relay - publish print complete
     try:
     try:
         printer_info = printer_manager.get_printer(printer_id)
         printer_info = printer_manager.get_printer(printer_id)

+ 5 - 0
backend/app/models/archive.py

@@ -69,10 +69,15 @@ class PrintArchive(Base):
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
 
+    # User tracking (who uploaded/created this archive)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
     # Relationships
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     project: Mapped["Project | None"] = relationship(back_populates="archives")
     project: Mapped["Project | None"] = relationship(back_populates="archives")
+    created_by: Mapped["User | None"] = relationship()
 
 
 
 
 from backend.app.models.printer import Printer  # noqa: E402, F811
 from backend.app.models.printer import Printer  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
+from backend.app.models.user import User  # noqa: E402, F811

+ 5 - 0
backend/app/models/library.py

@@ -82,6 +82,9 @@ class LibraryFile(Base):
     # User notes
     # User notes
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
 
 
+    # User tracking (Issue #206)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -89,7 +92,9 @@ class LibraryFile(Base):
     # Relationships
     # Relationships
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     project: Mapped["Project | None"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
+from backend.app.models.user import User  # noqa: E402, F811

+ 5 - 0
backend/app/models/print_queue.py

@@ -68,14 +68,19 @@ class PrintQueueItem(Base):
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
 
 
+    # User tracking (who added this to the queue)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
     # Relationships
     # Relationships
     printer: Mapped["Printer"] = relationship()
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
+    created_by: Mapped["User | None"] = relationship()
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
 from backend.app.models.project import Project  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

+ 1 - 1
backend/app/models/printer.py

@@ -40,7 +40,7 @@ class Printer(Base):
 
 
     # Relationships
     # Relationships
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
     archives: Mapped[list["PrintArchive"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
-    smart_plug: Mapped["SmartPlug | None"] = relationship(back_populates="printer", uselist=False)
+    smart_plugs: Mapped[list["SmartPlug"]] = relationship(back_populates="printer")
     notification_providers: Mapped[list["NotificationProvider"]] = relationship(back_populates="printer")
     notification_providers: Mapped[list["NotificationProvider"]] = relationship(back_populates="printer")
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         back_populates="printer", cascade="all, delete-orphan"

+ 3 - 5
backend/app/models/smart_plug.py

@@ -50,10 +50,8 @@ class SmartPlug(Base):
     # Legacy multiplier - kept for backward compatibility
     # Legacy multiplier - kept for backward compatibility
     mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
     mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
 
 
-    # Link to printer (1:1)
-    printer_id: Mapped[int | None] = mapped_column(
-        ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
-    )
+    # Link to printer (multiple plugs/scripts can be linked to one printer)
+    printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
 
 
     # Automation settings
     # Automation settings
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
     enabled: Mapped[bool] = mapped_column(Boolean, default=True)
@@ -100,7 +98,7 @@ class SmartPlug(Base):
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 
     # Relationship
     # Relationship
-    printer: Mapped["Printer"] = relationship(back_populates="smart_plug")
+    printer: Mapped["Printer"] = relationship(back_populates="smart_plugs")
 
 
 
 
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402

+ 4 - 0
backend/app/schemas/archive.py

@@ -89,6 +89,10 @@ class ArchiveResponse(BaseModel):
 
 
     created_at: datetime
     created_at: datetime
 
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     @model_validator(mode="after")
     @model_validator(mode="after")
     def compute_object_count(self) -> "ArchiveResponse":
     def compute_object_count(self) -> "ArchiveResponse":
         """Compute object_count from extra_data.printable_objects if not set."""
         """Compute object_count from extra_data.printable_objects if not set."""

+ 7 - 0
backend/app/schemas/library.py

@@ -123,6 +123,10 @@ class FileResponse(BaseModel):
     duplicates: list[FileDuplicate] | None = None
     duplicates: list[FileDuplicate] | None = None
     duplicate_count: int = 0
     duplicate_count: int = 0
 
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
 
 
@@ -141,6 +145,9 @@ class FileListResponse(BaseModel):
     thumbnail_path: str | None
     thumbnail_path: str | None
     print_count: int
     print_count: int
     duplicate_count: int = 0
     duplicate_count: int = 0
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
     created_at: datetime
     created_at: datetime
 
 
     # Key metadata fields for display
     # Key metadata fields for display

+ 4 - 0
backend/app/schemas/print_queue.py

@@ -95,6 +95,10 @@ class PrintQueueItemResponse(BaseModel):
     printer_name: str | None = None
     printer_name: str | None = None
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
     print_time_seconds: int | None = None  # Estimated print time from archive or library file
 
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 

+ 19 - 4
backend/app/services/archive.py

@@ -806,8 +806,16 @@ class ArchiveService:
         printer_id: int | None,
         printer_id: int | None,
         source_file: Path,
         source_file: Path,
         print_data: dict | None = None,
         print_data: dict | None = None,
+        created_by_id: int | None = None,
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
-        """Archive a 3MF file with metadata."""
+        """Archive a 3MF file with metadata.
+
+        Args:
+            printer_id: ID of the printer (optional)
+            source_file: Path to the 3MF file
+            print_data: Print data from MQTT (optional)
+            created_by_id: User ID who created this archive (optional, for user tracking)
+        """
         # Verify printer exists if specified
         # Verify printer exists if specified
         if printer_id is not None:
         if printer_id is not None:
             result = await self.db.execute(select(Printer).where(Printer.id == printer_id))
             result = await self.db.execute(select(Printer).where(Printer.id == printer_id))
@@ -915,6 +923,7 @@ class ArchiveService:
             cost=cost,
             cost=cost,
             quantity=quantity,
             quantity=quantity,
             extra_data=metadata,
             extra_data=metadata,
+            created_by_id=created_by_id,
         )
         )
 
 
         self.db.add(archive)
         self.db.add(archive)
@@ -924,8 +933,12 @@ class ArchiveService:
         return archive
         return archive
 
 
     async def get_archive(self, archive_id: int) -> PrintArchive | None:
     async def get_archive(self, archive_id: int) -> PrintArchive | None:
-        """Get an archive by ID."""
-        result = await self.db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+        """Get an archive by ID with creator loaded."""
+        from sqlalchemy.orm import selectinload
+
+        result = await self.db.execute(
+            select(PrintArchive).options(selectinload(PrintArchive.created_by)).where(PrintArchive.id == archive_id)
+        )
         return result.scalar_one_or_none()
         return result.scalar_one_or_none()
 
 
     async def update_archive_status(
     async def update_archive_status(
@@ -997,7 +1010,9 @@ class ArchiveService:
         from sqlalchemy.orm import selectinload
         from sqlalchemy.orm import selectinload
 
 
         query = (
         query = (
-            select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+            .order_by(PrintArchive.created_at.desc())
         )
         )
 
 
         if printer_id:
         if printer_id:

+ 102 - 4
backend/app/services/bambu_ftp.py

@@ -107,17 +107,32 @@ class BambuFTPClient:
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
                 f"FTP connecting to {self.ip_address}:{self.FTP_PORT} "
                 f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
                 f"(timeout={self.timeout}s, model={self.printer_model}, skip_session_reuse={skip_reuse})"
             )
             )
-            self._ftp = ImplicitFTP_TLS()
+            self._ftp = ImplicitFTP_TLS(skip_session_reuse=skip_reuse)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=self.timeout)
             logger.debug("FTP connected, logging in as bblp")
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             self._ftp.login("bblp", self.access_code)
             logger.debug("FTP logged in, setting prot_p and passive mode")
             logger.debug("FTP logged in, setting prot_p and passive mode")
             self._ftp.prot_p()
             self._ftp.prot_p()
             self._ftp.set_pasv(True)
             self._ftp.set_pasv(True)
-            logger.info(f"FTP connected successfully to {self.ip_address}")
+            # Log welcome message for debugging
+            if hasattr(self._ftp, "welcome") and self._ftp.welcome:
+                logger.debug(f"FTP server welcome: {self._ftp.welcome}")
+            logger.info(f"FTP connected successfully to {self.ip_address} (model={self.printer_model})")
             return True
             return True
+        except ftplib.error_perm as e:
+            logger.warning(f"FTP connection permission error to {self.ip_address}: {e}")
+            self._ftp = None
+            return False
+        except TimeoutError as e:
+            logger.warning(f"FTP connection timed out to {self.ip_address}: {e}")
+            self._ftp = None
+            return False
+        except ssl.SSLError as e:
+            logger.warning(f"FTP SSL error connecting to {self.ip_address}: {e}")
+            self._ftp = None
+            return False
         except Exception as e:
         except Exception as e:
-            logger.warning(f"FTP connection failed to {self.ip_address}: {e}")
+            logger.warning(f"FTP connection failed to {self.ip_address}: {e} (type: {type(e).__name__})")
             self._ftp = None
             self._ftp = None
             return False
             return False
 
 
@@ -227,6 +242,62 @@ class BambuFTPClient:
                     pass
                     pass
             return False
             return False
 
 
+    def diagnose_storage(self) -> dict:
+        """Run storage diagnostics and return results. For debugging upload issues."""
+        results = {
+            "connected": self._ftp is not None,
+            "can_list_root": False,
+            "root_files": [],
+            "can_list_cache": False,
+            "storage_info": None,
+            "pwd": None,
+            "errors": [],
+        }
+
+        if not self._ftp:
+            results["errors"].append("FTP not connected")
+            return results
+
+        # Try to get current directory
+        try:
+            results["pwd"] = self._ftp.pwd()
+            logger.debug(f"FTP current directory: {results['pwd']}")
+        except Exception as e:
+            results["errors"].append(f"PWD failed: {e}")
+            logger.debug(f"FTP PWD failed: {e}")
+
+        # Try to list root directory
+        try:
+            self._ftp.cwd("/")
+            items = []
+            self._ftp.retrlines("LIST", items.append)
+            results["can_list_root"] = True
+            results["root_files"] = items[:10]  # First 10 entries
+            logger.debug(f"FTP root listing ({len(items)} items): {items[:5]}")
+        except Exception as e:
+            results["errors"].append(f"LIST / failed: {e}")
+            logger.debug(f"FTP LIST / failed: {e}")
+
+        # Try to list /cache (should exist on all printers)
+        try:
+            self._ftp.cwd("/cache")
+            items = []
+            self._ftp.retrlines("LIST", items.append)
+            results["can_list_cache"] = True
+            logger.debug(f"FTP /cache listing: {len(items)} items")
+        except Exception as e:
+            results["errors"].append(f"LIST /cache failed: {e}")
+            logger.debug(f"FTP LIST /cache failed: {e}")
+
+        # Try to get storage info
+        try:
+            results["storage_info"] = self.get_storage_info()
+            logger.debug(f"FTP storage info: {results['storage_info']}")
+        except Exception as e:
+            results["errors"].append(f"Storage info failed: {e}")
+
+        return results
+
     def upload_file(
     def upload_file(
         self,
         self,
         local_path: Path,
         local_path: Path,
@@ -242,6 +313,17 @@ class BambuFTPClient:
             file_size = local_path.stat().st_size if local_path.exists() else 0
             file_size = local_path.stat().st_size if local_path.exists() else 0
             logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
             logger.info(f"FTP uploading {local_path} ({file_size} bytes) to {remote_path}")
 
 
+            # Run storage diagnostics before upload (debug)
+            logger.debug("Running pre-upload storage diagnostics...")
+            diag = self.diagnose_storage()
+            logger.info(
+                f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, "
+                f"can_list_cache={diag['can_list_cache']}, "
+                f"storage={diag['storage_info']}, errors={diag['errors']}"
+            )
+            if diag["root_files"]:
+                logger.debug(f"FTP root directory contents: {diag['root_files']}")
+
             uploaded = 0
             uploaded = 0
 
 
             def on_block(block: bytes):
             def on_block(block: bytes):
@@ -254,11 +336,27 @@ class BambuFTPClient:
                 if self._should_skip_session_reuse():
                 if self._should_skip_session_reuse():
                     ftplib._SSLSocket = None
                     ftplib._SSLSocket = None
 
 
+                logger.debug(f"FTP STOR command starting for {remote_path}")
                 self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
                 self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
             logger.info(f"FTP upload complete: {remote_path}")
             logger.info(f"FTP upload complete: {remote_path}")
             return True
             return True
+        except ftplib.error_perm as e:
+            # Permanent FTP error (4xx/5xx response)
+            error_code = str(e)[:3] if str(e) else "unknown"
+            logger.error(f"FTP upload failed for {remote_path}: {e} (error code: {error_code})")
+            if error_code == "553":
+                logger.error(
+                    "FTP 553 error - Could not create file. Possible causes: "
+                    "1) No SD card inserted, 2) SD card full, 3) SD card not formatted correctly (needs FAT32/exFAT), "
+                    "4) Printer busy/not ready, 5) File path issue"
+                )
+            elif error_code == "550":
+                logger.error("FTP 550 error - File/directory not found or permission denied")
+            elif error_code == "552":
+                logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
+            return False
         except Exception as e:
         except Exception as e:
-            logger.error(f"FTP upload failed for {remote_path}: {e}")
+            logger.error(f"FTP upload failed for {remote_path}: {e} (type: {type(e).__name__})")
             return False
             return False
 
 
     def upload_bytes(self, data: bytes, remote_path: str) -> bool:
     def upload_bytes(self, data: bytes, remote_path: str) -> bool:

+ 7 - 2
backend/app/services/bambu_mqtt.py

@@ -2033,10 +2033,15 @@ class BambuMQTTClient:
                 for tray_id in ams_mapping:
                 for tray_id in ams_mapping:
                     # Ensure tray_id is an integer (may be string from JSON)
                     # Ensure tray_id is an integer (may be string from JSON)
                     tray_id = int(tray_id) if tray_id is not None else -1
                     tray_id = int(tray_id) if tray_id is not None else -1
-                    if tray_id == -1 or tray_id == 255:
+                    if tray_id == -1:
+                        # Unmapped filament slot
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
+                    elif tray_id >= 254:
+                        # External spool: 254 = main nozzle, 255 = deputy nozzle
+                        # External spools use ams_id=255 with slot_id matching tray_id
+                        ams_mapping2.append({"ams_id": 255, "slot_id": tray_id})
                     else:
                     else:
-                        # Global tray ID = (ams_id * 4) + slot_id
+                        # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         ams_id = tray_id // 4
                         ams_id = tray_id // 4
                         slot_id = tray_id % 4
                         slot_id = tray_id % 4
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})

+ 21 - 7
backend/app/services/print_scheduler.py

@@ -857,17 +857,25 @@ class PrintScheduler:
         # Get FTP retry settings
         # Get FTP retry settings
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
         ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
 
 
+        logger.info(
+            f"Queue item {item.id}: FTP upload starting - printer={printer.name} ({printer.model}), "
+            f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
+            f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
+        )
+
         # Delete existing file if present (avoids 553 error on overwrite)
         # Delete existing file if present (avoids 553 error on overwrite)
         try:
         try:
-            await delete_file_async(
+            logger.debug(f"Queue item {item.id}: Deleting existing file {remote_path} if present...")
+            delete_result = await delete_file_async(
                 printer.ip_address,
                 printer.ip_address,
                 printer.access_code,
                 printer.access_code,
                 remote_path,
                 remote_path,
                 socket_timeout=ftp_timeout,
                 socket_timeout=ftp_timeout,
                 printer_model=printer.model,
                 printer_model=printer.model,
             )
             )
-        except Exception:
-            pass  # File may not exist, that's fine
+            logger.debug(f"Queue item {item.id}: Delete result: {delete_result}")
+        except Exception as e:
+            logger.debug(f"Queue item {item.id}: Delete failed (may not exist): {e}")
 
 
         try:
         try:
             if ftp_retry_enabled:
             if ftp_retry_enabled:
@@ -894,14 +902,21 @@ class PrintScheduler:
                 )
                 )
         except Exception as e:
         except Exception as e:
             uploaded = False
             uploaded = False
-            logger.error(f"Queue item {item.id}: FTP error: {e}")
+            logger.error(f"Queue item {item.id}: FTP error: {e} (type: {type(e).__name__})")
 
 
         if not uploaded:
         if not uploaded:
+            error_msg = (
+                "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
+                "See server logs for detailed diagnostics."
+            )
             item.status = "failed"
             item.status = "failed"
-            item.error_message = "Failed to upload file to printer"
+            item.error_message = error_msg
             item.completed_at = datetime.utcnow()
             item.completed_at = datetime.utcnow()
             await db.commit()
             await db.commit()
-            logger.error(f"Queue item {item.id}: FTP upload failed")
+            logger.error(
+                f"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, "
+                f"ip={printer.ip_address}. Check logs above for storage diagnostics and specific error codes."
+            )
 
 
             # Send failure notification
             # Send failure notification
             await notification_service.on_queue_job_failed(
             await notification_service.on_queue_job_failed(
@@ -911,7 +926,6 @@ class PrintScheduler:
                 reason="Failed to upload file to printer",
                 reason="Failed to upload file to printer",
                 db=db,
                 db=db,
             )
             )
-
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
             return
             return
 
 

+ 14 - 0
backend/app/services/printer_manager.py

@@ -98,11 +98,25 @@ class PrinterManager:
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
+        # Track who started the current print (Issue #206)
+        self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
 
 
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
     def get_printer(self, printer_id: int) -> PrinterInfo | None:
         """Get printer info by ID."""
         """Get printer info by ID."""
         return self._printer_info.get(printer_id)
         return self._printer_info.get(printer_id)
 
 
+    def set_current_print_user(self, printer_id: int, user_id: int, username: str):
+        """Track who started the current print (Issue #206)."""
+        self._current_print_user[printer_id] = {"user_id": user_id, "username": username}
+
+    def get_current_print_user(self, printer_id: int) -> dict | None:
+        """Get the user who started the current print (Issue #206)."""
+        return self._current_print_user.get(printer_id)
+
+    def clear_current_print_user(self, printer_id: int):
+        """Clear the current print user when print completes (Issue #206)."""
+        self._current_print_user.pop(printer_id, None)
+
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
         """Set the event loop for async callbacks."""
         """Set the event loop for async callbacks."""
         self._loop = loop
         self._loop = loop

+ 133 - 0
backend/tests/integration/test_library_api.py

@@ -254,6 +254,74 @@ class TestLibraryFilesAPI:
         assert result["total_folders"] == 2
         assert result["total_folders"] == 2
         assert result["total_files"] == 1
         assert result["total_files"] == 1
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file list response includes user tracking fields (Issue #206)."""
+        lib_file = await file_factory(filename="test.3mf")
+        response = await async_client.get("/api/v1/library/files?include_root=false")
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result) >= 1
+        # Find our test file
+        test_file = next((f for f in result if f["id"] == lib_file.id), None)
+        assert test_file is not None
+        # User tracking fields should be present (even if null)
+        assert "created_by_id" in test_file
+        assert "created_by_username" in test_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file detail response includes user tracking fields (Issue #206)."""
+        lib_file = await file_factory(filename="test_detail.3mf")
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
+        assert response.status_code == 200
+        result = response.json()
+        # User tracking fields should be present (even if null)
+        assert "created_by_id" in result
+        assert "created_by_username" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
+        """Verify file created with user shows username in response (Issue #206)."""
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.user import User
+
+        # Create a test user
+        user = User(username="testuploader", password_hash="fakehash", role="user")
+        db_session.add(user)
+        await db_session.flush()
+
+        # Create a file with created_by_id set
+        lib_file = LibraryFile(
+            filename="user_uploaded.3mf",
+            file_path="/test/user_uploaded.3mf",
+            file_size=2048,
+            file_type="3mf",
+            created_by_id=user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        # Verify file detail shows username
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["created_by_id"] == user.id
+        assert result["created_by_username"] == "testuploader"
+
+        # Verify file list also shows username
+        response = await async_client.get("/api/v1/library/files?include_root=false")
+        assert response.status_code == 200
+        files = response.json()
+        test_file = next((f for f in files if f["id"] == lib_file.id), None)
+        assert test_file is not None
+        assert test_file["created_by_id"] == user.id
+        assert test_file["created_by_username"] == "testuploader"
+
 
 
 class TestLibraryAddToQueueAPI:
 class TestLibraryAddToQueueAPI:
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
@@ -715,3 +783,68 @@ endsolid cube"""
         file_ids = {r["file_id"] for r in result["results"]}
         file_ids = {r["file_id"] for r in result["results"]}
         assert stl_without_thumb1.id in file_ids
         assert stl_without_thumb1.id in file_ids
         assert stl_without_thumb2.id in file_ids
         assert stl_without_thumb2.id in file_ids
+
+
+class TestLibraryPathHelpers:
+    """Tests for path handling utilities used for backup portability."""
+
+    def test_to_relative_path_converts_absolute(self):
+        """Verify absolute paths are converted to relative paths."""
+        from backend.app.api.routes.library import to_relative_path
+        from backend.app.core.config import settings
+
+        base_dir = str(settings.base_dir)
+        abs_path = f"{base_dir}/archive/library/files/test.3mf"
+        rel_path = to_relative_path(abs_path)
+
+        assert not rel_path.startswith("/")
+        assert rel_path == "archive/library/files/test.3mf"
+
+    def test_to_relative_path_handles_path_object(self):
+        """Verify Path objects are handled correctly."""
+        from pathlib import Path
+
+        from backend.app.api.routes.library import to_relative_path
+        from backend.app.core.config import settings
+
+        abs_path = Path(settings.base_dir) / "archive" / "test.3mf"
+        rel_path = to_relative_path(abs_path)
+
+        assert not rel_path.startswith("/")
+        assert rel_path == "archive/test.3mf"
+
+    def test_to_relative_path_returns_empty_for_empty_input(self):
+        """Verify empty input returns empty string."""
+        from backend.app.api.routes.library import to_relative_path
+
+        assert to_relative_path("") == ""
+        assert to_relative_path(None) == ""
+
+    def test_to_absolute_path_converts_relative(self):
+        """Verify relative paths are converted to absolute paths."""
+        from backend.app.api.routes.library import to_absolute_path
+        from backend.app.core.config import settings
+
+        rel_path = "archive/library/files/test.3mf"
+        abs_path = to_absolute_path(rel_path)
+
+        assert abs_path is not None
+        assert abs_path.is_absolute()
+        assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf"
+
+    def test_to_absolute_path_handles_already_absolute(self):
+        """Verify already absolute paths are returned as-is (for backwards compatibility)."""
+        from backend.app.api.routes.library import to_absolute_path
+
+        abs_path_str = "/data/archive/test.3mf"
+        result = to_absolute_path(abs_path_str)
+
+        assert result is not None
+        assert str(result) == abs_path_str
+
+    def test_to_absolute_path_returns_none_for_empty(self):
+        """Verify None/empty input returns None."""
+        from backend.app.api.routes.library import to_absolute_path
+
+        assert to_absolute_path(None) is None
+        assert to_absolute_path("") is None

+ 740 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -0,0 +1,740 @@
+"""Integration tests for ownership-based permission system.
+
+Tests the ownership permission model where users can have:
+- *_all permissions: can modify any item
+- *_own permissions: can only modify items they created
+- Ownerless items (created_by_id = null) require *_all permission
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestOwnershipPermissionsSetup:
+    """Helper fixture class for ownership permission tests."""
+
+    @pytest.fixture
+    async def auth_setup(self, async_client: AsyncClient):
+        """Setup auth with admin, create test users with different permission levels."""
+        # Enable auth with admin user
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "ownershipadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        # Login as admin
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "ownershipadmin", "password": "adminpassword123"},
+        )
+        admin_token = admin_login.json()["access_token"]
+        admin_user = admin_login.json()["user"]
+
+        # Get group IDs
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        groups = groups_response.json()
+        operators_group = next(g for g in groups if g["name"] == "Operators")
+        viewers_group = next(g for g in groups if g["name"] == "Viewers")
+
+        # Create operator user (has *_own permissions)
+        operator_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={
+                "username": "operator1",
+                "password": "operatorpass123",
+                "group_ids": [operators_group["id"]],
+            },
+        )
+        operator_user = operator_response.json()
+
+        # Login as operator
+        operator_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "operator1", "password": "operatorpass123"},
+        )
+        operator_token = operator_login.json()["access_token"]
+
+        # Create second operator (for cross-user tests)
+        operator2_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={
+                "username": "operator2",
+                "password": "operatorpass123",
+                "group_ids": [operators_group["id"]],
+            },
+        )
+        operator2_user = operator2_response.json()
+
+        operator2_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "operator2", "password": "operatorpass123"},
+        )
+        operator2_token = operator2_login.json()["access_token"]
+
+        # Create viewer user (has no update/delete permissions)
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={
+                "username": "viewer1",
+                "password": "viewerpass123",
+                "group_ids": [viewers_group["id"]],
+            },
+        )
+
+        viewer_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "viewer1", "password": "viewerpass123"},
+        )
+        viewer_token = viewer_login.json()["access_token"]
+
+        return {
+            "admin_token": admin_token,
+            "admin_user": admin_user,
+            "operator_token": operator_token,
+            "operator_user": operator_user,
+            "operator2_token": operator2_token,
+            "operator2_user": operator2_user,
+            "viewer_token": viewer_token,
+        }
+
+
+class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
+    """Tests for archive ownership-based permissions."""
+
+    # ========================================================================
+    # DELETE permissions
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_delete_any_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Admin with *_all permissions can delete any archive."""
+        printer = await printer_factory()
+        # Create archive owned by operator
+        archive = await archive_factory(
+            printer.id,
+            print_name="Operator Archive",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+
+        # Admin deletes it
+        response = await async_client.delete(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_delete_own_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator with *_own permissions can delete their own archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="My Archive",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+
+        response = await async_client.delete(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_others_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator with *_own permissions cannot delete another user's archive."""
+        printer = await printer_factory()
+        # Archive created by operator2
+        archive = await archive_factory(
+            printer.id,
+            print_name="Other's Archive",
+            created_by_id=auth_setup["operator2_user"]["id"],
+        )
+
+        # operator1 tries to delete it
+        response = await async_client.delete(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+        assert "your own" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_ownerless_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator with *_own permissions cannot delete ownerless archive."""
+        printer = await printer_factory()
+        # Archive with no owner (legacy data)
+        archive = await archive_factory(
+            printer.id,
+            print_name="Ownerless Archive",
+            created_by_id=None,
+        )
+
+        response = await async_client.delete(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_cannot_delete_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Viewer with no delete permissions cannot delete any archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Any Archive")
+
+        response = await async_client.delete(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    # ========================================================================
+    # UPDATE permissions
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_update_any_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Admin can update any archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Original Name",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={"print_name": "Admin Updated"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["print_name"] == "Admin Updated"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_update_own_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator can update their own archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Original Name",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"print_name": "Operator Updated"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["print_name"] == "Operator Updated"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_update_others_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator cannot update another user's archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Other's Archive",
+            created_by_id=auth_setup["operator2_user"]["id"],
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"print_name": "Attempted Update"},
+        )
+
+        assert response.status_code == 403
+
+    # ========================================================================
+    # REPRINT permissions
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_reprint_others_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator cannot reprint another user's archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            created_by_id=auth_setup["operator2_user"]["id"],
+        )
+
+        response = await async_client.post(
+            f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+
+class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):
+    """Tests for print queue ownership-based permissions."""
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+
+        async def _create_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            printer = await printer_factory()
+            # Create an archive to link to the queue item
+            archive = await archive_factory(printer.id)
+
+            defaults = {
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "status": "pending",
+                "position": 0,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_delete_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Admin can delete any queue item."""
+        item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/queue/{item.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_delete_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Operator can delete their own queue item."""
+        item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/queue/{item.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_others_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory
+    ):
+        """Operator cannot delete another user's queue item."""
+        item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/queue/{item.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_update_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Operator can update their own queue item."""
+        item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"position": 10},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_update_others_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory
+    ):
+        """Operator cannot update another user's queue item."""
+        item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"position": 10},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_cancel_others_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory
+    ):
+        """Operator cannot cancel another user's queue item."""
+        item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/cancel",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_update_skips_non_owned_items(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Bulk update only updates items the user owns."""
+        # Create items owned by different users
+        own_item = await queue_item_factory(
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        other_item = await queue_item_factory(
+            created_by_id=auth_setup["operator2_user"]["id"],
+        )
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={
+                "item_ids": [own_item.id, other_item.id],
+                "manual_start": True,
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        # Should only update the owned item
+        assert result["updated_count"] == 1
+        assert result["skipped_count"] == 1
+
+
+class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
+    """Tests for library file ownership-based permissions."""
+
+    @pytest.fixture
+    async def library_file_factory(self, db_session):
+        """Factory to create test library files."""
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            defaults = {
+                "filename": f"test_{_counter[0]}.3mf",
+                "file_path": f"library/test_{_counter[0]}.3mf",
+                "file_type": "3mf",
+                "file_size": 1024,
+            }
+            defaults.update(kwargs)
+
+            file = LibraryFile(**defaults)
+            db_session.add(file)
+            await db_session.commit()
+            await db_session.refresh(file)
+            return file
+
+        return _create_file
+
+    @pytest.fixture
+    async def library_folder_factory(self, db_session):
+        """Factory to create test library folders."""
+        _counter = [0]
+
+        async def _create_folder(**kwargs):
+            from backend.app.models.library import LibraryFolder
+
+            _counter[0] += 1
+            defaults = {
+                "name": f"TestFolder_{_counter[0]}",
+            }
+            defaults.update(kwargs)
+
+            folder = LibraryFolder(**defaults)
+            db_session.add(folder)
+            await db_session.commit()
+            await db_session.refresh(folder)
+            return folder
+
+        return _create_folder
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_delete_any_library_file(self, async_client: AsyncClient, auth_setup, library_file_factory):
+        """Admin can delete any library file."""
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{file.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_delete_own_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        """Operator can delete their own library file."""
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{file.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_others_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        """Operator cannot delete another user's library file."""
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{file.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_update_own_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        """Operator can update their own library file."""
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.put(
+            f"/api/v1/library/files/{file.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"filename": "renamed.3mf"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_update_others_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory
+    ):
+        """Operator cannot update another user's library file."""
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.put(
+            f"/api/v1/library/files/{file.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"filename": "renamed.3mf"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory):
+        """Folders require *_all permission (no ownership tracking on folders)."""
+        folder = await library_folder_factory(name="TestFolder")
+
+        # Operator cannot delete folder (needs *_all)
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):
+        """Bulk delete only deletes files the user owns."""
+        own_file = await library_file_factory(
+            filename="own.3mf",
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+        other_file = await library_file_factory(
+            filename="other.3mf",
+            created_by_id=auth_setup["operator2_user"]["id"],
+        )
+
+        response = await async_client.post(
+            "/api/v1/library/bulk-delete",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [own_file.id, other_file.id], "folder_ids": []},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        # Should only delete the owned file; other_file is skipped (but skipped count not in response)
+        assert result["deleted_files"] == 1
+
+
+class TestAuthDisabledPermissions:
+    """Tests that verify all operations are allowed when auth is disabled."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_archive_without_auth(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """When auth is disabled, anyone can delete archives."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.delete(f"/api/v1/archives/{archive.id}")
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_without_auth(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """When auth is disabled, anyone can update archives."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"print_name": "Updated Name"},
+        )
+
+        assert response.status_code == 200
+
+
+class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
+    """Tests for user items count endpoint and deletion with items."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_user_items_count(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Verify items count endpoint returns correct counts."""
+        printer = await printer_factory()
+        user_id = auth_setup["operator_user"]["id"]
+
+        # Create some items for the operator
+        await archive_factory(printer.id, created_by_id=user_id)
+        await archive_factory(printer.id, created_by_id=user_id)
+
+        response = await async_client.get(
+            f"/api/v1/users/{user_id}/items-count",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+        counts = response.json()
+        assert counts["archives"] >= 2
+        assert "queue_items" in counts
+        assert "library_files" in counts
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_keeps_items(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Verify deleting user without delete_items keeps items (ownerless)."""
+        printer = await printer_factory()
+        user_id = auth_setup["operator2_user"]["id"]
+
+        # Create archive for operator2
+        archive = await archive_factory(printer.id, created_by_id=user_id)
+        archive_id = archive.id
+
+        # Delete user without deleting items
+        response = await async_client.delete(
+            f"/api/v1/users/{user_id}?delete_items=false",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 204
+
+        # Verify archive still exists but is now ownerless
+        archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
+        assert archive_response.status_code == 200
+        assert archive_response.json()["created_by_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_with_items(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Verify deleting user with delete_items=true removes their items."""
+        printer = await printer_factory()
+
+        # Create a new user with items
+        create_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+            json={
+                "username": "deletewithitems",
+                "password": "password123",
+            },
+        )
+        user_id = create_response.json()["id"]
+
+        # Create archive for this user
+        archive = await archive_factory(printer.id, created_by_id=user_id)
+        archive_id = archive.id
+
+        # Delete user WITH deleting items
+        response = await async_client.delete(
+            f"/api/v1/users/{user_id}?delete_items=true",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 204
+
+        # Verify archive was deleted
+        archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
+        assert archive_response.status_code == 404

+ 41 - 0
backend/tests/integration/test_printers_api.py

@@ -292,6 +292,47 @@ class TestPrinterDataIntegrity:
             assert response.json()["status"] == "refresh_requested"
             assert response.json()["status"] == "refresh_requested"
             mock_pm.request_status_update.assert_called_once_with(printer.id)
             mock_pm.request_status_update.assert_called_once_with(printer.id)
 
 
+    # ========================================================================
+    # Current print user endpoint (Issue #206)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_current_print_user_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/99999/current-print-user")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_current_print_user_returns_empty_when_no_user(self, async_client: AsyncClient, printer_factory):
+        """Verify empty object returned when no user is tracked."""
+        printer = await printer_factory(name="Test Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_current_print_user.return_value = None
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
+
+            assert response.status_code == 200
+            assert response.json() == {}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_current_print_user_returns_user_info(self, async_client: AsyncClient, printer_factory):
+        """Verify user info is returned when tracked."""
+        printer = await printer_factory(name="Test Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_current_print_user.return_value = {"user_id": 42, "username": "testuser"}
+
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/current-print-user")
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["user_id"] == 42
+            assert result["username"] == "testuser"
+
 
 
 class TestPrintControlAPI:
 class TestPrintControlAPI:
     """Integration tests for print control endpoints (stop, pause, resume)."""
     """Integration tests for print control endpoints (stop, pause, resume)."""

+ 35 - 70
backend/tests/integration/test_settings_api.py

@@ -393,85 +393,50 @@ class TestSettingsAPI:
         # Default is False as defined in schema
         # Default is False as defined in schema
         assert isinstance(result["per_printer_mapping_expanded"], bool)
         assert isinstance(result["per_printer_mapping_expanded"], bool)
 
 
-    # ========================================================================
-    # Backup/Restore tests
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_backup_includes_external_camera_settings(self, async_client: AsyncClient, printer_factory):
-        """Verify backup includes external camera settings for printers."""
-        # Create a printer with external camera settings
-        _printer = await printer_factory(
-            name="Camera Test Printer",
-            external_camera_url="/dev/video0",
-            external_camera_type="usb",
-            external_camera_enabled=True,
-        )
 
 
-        # Request backup with printers
-        response = await async_client.get("/api/v1/settings/backup?include_printers=true")
+class TestSimplifiedBackupRestore:
+    """Integration tests for the simplified backup/restore endpoints (ZIP-based).
 
 
-        assert response.status_code == 200
-        backup = response.json()
+    Note: Tests that require actual file operations (backup creation) are skipped
+    because the test suite uses an in-memory database. These tests focus on
+    validation and error handling which don't require file I/O.
+    """
 
 
-        # Find the printer in the backup
-        assert "printers" in backup
-        printer_data = next((p for p in backup["printers"] if p["name"] == "Camera Test Printer"), None)
-        assert printer_data is not None
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_requires_zip_file(self, async_client: AsyncClient):
+        """Verify restore rejects non-ZIP files."""
+        files = {"file": ("backup.txt", b"not a zip file", "text/plain")}
+        response = await async_client.post("/api/v1/settings/restore", files=files)
 
 
-        # Verify external camera fields are included
-        assert "external_camera_url" in printer_data
-        assert "external_camera_type" in printer_data
-        assert "external_camera_enabled" in printer_data
-        assert printer_data["external_camera_url"] == "/dev/video0"
-        assert printer_data["external_camera_type"] == "usb"
-        assert printer_data["external_camera_enabled"] is True
+        assert response.status_code == 400
+        assert "zip" in response.json()["detail"].lower()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_restore_external_camera_settings_overwrite(self, async_client: AsyncClient, printer_factory):
-        """Verify restore with overwrite updates external camera settings."""
+    async def test_restore_requires_database_in_zip(self, async_client: AsyncClient):
+        """Verify restore rejects ZIP without database file."""
         import io
         import io
+        import zipfile
 
 
-        # Create a printer without camera settings
-        printer = await printer_factory(
-            name="Restore Test",
-            external_camera_url=None,
-            external_camera_type=None,
-            external_camera_enabled=False,
-        )
+        # Create a ZIP without bambuddy.db
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("dummy.txt", "dummy content")
+        zip_buffer.seek(0)
 
 
-        # Create backup data with camera settings
-        backup_data = {
-            "version": "1.0",
-            "included": ["printers"],
-            "printers": [
-                {
-                    "name": "Restore Test",
-                    "serial_number": printer.serial_number,
-                    "ip_address": printer.ip_address,
-                    "external_camera_url": "/dev/video1",
-                    "external_camera_type": "usb",
-                    "external_camera_enabled": True,
-                }
-            ],
-        }
-
-        # Restore with overwrite
-        import json
-
-        files = {"file": ("backup.json", io.BytesIO(json.dumps(backup_data).encode()), "application/json")}
-        response = await async_client.post("/api/v1/settings/restore?overwrite=true", files=files)
+        files = {"file": ("backup.zip", zip_buffer.read(), "application/zip")}
+        response = await async_client.post("/api/v1/settings/restore", files=files)
 
 
-        assert response.status_code == 200
-        result = response.json()
-        assert result["success"] is True
+        assert response.status_code == 400
+        assert "missing bambuddy.db" in response.json()["detail"].lower()
 
 
-        # Verify the printer was updated
-        response = await async_client.get(f"/api/v1/printers/{printer.id}")
-        assert response.status_code == 200
-        updated_printer = response.json()
-        assert updated_printer["external_camera_url"] == "/dev/video1"
-        assert updated_printer["external_camera_type"] == "usb"
-        assert updated_printer["external_camera_enabled"] is True
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_restore_invalid_zip(self, async_client: AsyncClient):
+        """Verify restore rejects corrupted ZIP files."""
+        files = {"file": ("backup.zip", b"not valid zip content", "application/zip")}
+        response = await async_client.post("/api/v1/settings/restore", files=files)
+
+        assert response.status_code == 400
+        assert "not a valid zip" in response.json()["detail"].lower()

+ 60 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -565,6 +565,66 @@ class TestPrinterManager:
             assert result["state"] is None
             assert result["state"] is None
             mock_instance.disconnect.assert_called_once()
             mock_instance.disconnect.assert_called_once()
 
 
+    # ========================================================================
+    # Tests for current print user tracking (Issue #206)
+    # ========================================================================
+
+    def test_set_current_print_user(self, manager):
+        """Verify current print user can be set."""
+        manager.set_current_print_user(1, 42, "testuser")
+
+        assert 1 in manager._current_print_user
+        assert manager._current_print_user[1]["user_id"] == 42
+        assert manager._current_print_user[1]["username"] == "testuser"
+
+    def test_get_current_print_user_returns_user(self, manager):
+        """Verify get_current_print_user returns the stored user."""
+        manager.set_current_print_user(1, 42, "testuser")
+
+        result = manager.get_current_print_user(1)
+
+        assert result is not None
+        assert result["user_id"] == 42
+        assert result["username"] == "testuser"
+
+    def test_get_current_print_user_returns_none_for_unknown(self, manager):
+        """Verify get_current_print_user returns None for unknown printer."""
+        result = manager.get_current_print_user(999)
+        assert result is None
+
+    def test_clear_current_print_user(self, manager):
+        """Verify current print user can be cleared."""
+        manager.set_current_print_user(1, 42, "testuser")
+        manager.clear_current_print_user(1)
+
+        result = manager.get_current_print_user(1)
+        assert result is None
+
+    def test_clear_current_print_user_no_error_for_unknown(self, manager):
+        """Verify clearing unknown printer doesn't raise error."""
+        # Should not raise
+        manager.clear_current_print_user(999)
+
+    def test_set_current_print_user_overwrites_existing(self, manager):
+        """Verify setting user overwrites existing value."""
+        manager.set_current_print_user(1, 42, "user1")
+        manager.set_current_print_user(1, 99, "user2")
+
+        result = manager.get_current_print_user(1)
+        assert result["user_id"] == 99
+        assert result["username"] == "user2"
+
+    def test_multiple_printers_have_separate_users(self, manager):
+        """Verify each printer tracks its own user separately."""
+        manager.set_current_print_user(1, 42, "user1")
+        manager.set_current_print_user(2, 99, "user2")
+
+        result1 = manager.get_current_print_user(1)
+        result2 = manager.get_current_print_user(2)
+
+        assert result1["username"] == "user1"
+        assert result2["username"] == "user2"
+
 
 
 class TestPrinterStateToDict:
 class TestPrinterStateToDict:
     """Tests for printer_state_to_dict helper function."""
     """Tests for printer_state_to_dict helper function."""

+ 133 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -207,6 +207,139 @@ describe('StreamOverlayPage', () => {
     });
     });
   });
   });
 
 
+  describe('FPS configuration', () => {
+    it('uses default FPS of 15 when not specified', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        expect(img.src).toContain('fps=15');
+      });
+    });
+
+    it('uses custom FPS when specified in query params', async () => {
+      renderOverlayPage(1, '?fps=30');
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        expect(img.src).toContain('fps=30');
+      });
+    });
+
+    it('clamps FPS to maximum of 30', async () => {
+      renderOverlayPage(1, '?fps=60');
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        expect(img.src).toContain('fps=30');
+      });
+    });
+
+    it('clamps FPS to minimum of 1', async () => {
+      renderOverlayPage(1, '?fps=0');
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        expect(img.src).toContain('fps=1');
+      });
+    });
+
+    it('handles invalid FPS value gracefully', async () => {
+      renderOverlayPage(1, '?fps=invalid');
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        // Should fall back to default of 15
+        expect(img.src).toContain('fps=15');
+      });
+    });
+  });
+
+  describe('camera toggle (status-only mode)', () => {
+    it('shows camera by default', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
+      });
+    });
+
+    it('hides camera when camera=false', async () => {
+      renderOverlayPage(1, '?camera=false');
+
+      await waitFor(() => {
+        // Status should still be visible
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+
+      // Camera should not be rendered
+      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
+    });
+
+    it('hides camera when camera=0', async () => {
+      renderOverlayPage(1, '?camera=0');
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
+    });
+
+    it('shows camera when camera=true', async () => {
+      renderOverlayPage(1, '?camera=true');
+
+      await waitFor(() => {
+        expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
+      });
+    });
+
+    it('shows camera when camera=1', async () => {
+      renderOverlayPage(1, '?camera=1');
+
+      await waitFor(() => {
+        expect(screen.getByAltText('Camera stream')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('combined parameters', () => {
+    it('supports fps and camera together', async () => {
+      renderOverlayPage(1, '?fps=25&camera=true');
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        expect(img.src).toContain('fps=25');
+      });
+    });
+
+    it('supports status-only with custom size', async () => {
+      renderOverlayPage(1, '?camera=false&size=large');
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument();
+    });
+
+    it('supports show parameter with fps', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+
+      renderOverlayPage(1, '?fps=20&show=progress');
+
+      await waitFor(() => {
+        const img = screen.getByAltText('Camera stream') as HTMLImageElement;
+        expect(img.src).toContain('fps=20');
+        expect(screen.getByText('45%')).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('offline state', () => {
   describe('offline state', () => {
     beforeEach(() => {
     beforeEach(() => {
       server.use(
       server.use(

+ 96 - 33
frontend/src/api/client.ts

@@ -324,6 +324,9 @@ export interface Archive {
   energy_kwh: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
   energy_cost: number | null;
   created_at: string;
   created_at: string;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
 }
 }
 
 
 export interface ArchiveStats {
 export interface ArchiveStats {
@@ -1094,6 +1097,9 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
+  // User tracking (Issue #206)
+  created_by_id?: number | null;
+  created_by_username?: string | null;
 }
 }
 
 
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
@@ -1721,10 +1727,15 @@ export interface ExternalLinkUpdate {
 
 
 // Permission type - all available permissions
 // Permission type - all available permissions
 export type Permission =
 export type Permission =
-  | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files'
-  | 'archives:read' | 'archives:create' | 'archives:update' | 'archives:delete' | 'archives:reprint'
-  | 'queue:read' | 'queue:create' | 'queue:update' | 'queue:delete' | 'queue:reorder'
-  | 'library:read' | 'library:upload' | 'library:update' | 'library:delete'
+  | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid'
+  | 'archives:read' | 'archives:create'
+  | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
+  | 'archives:reprint_own' | 'archives:reprint_all'
+  | 'queue:read' | 'queue:create'
+  | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
+  | 'queue:reorder'
+  | 'library:read' | 'library:upload'
+  | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
@@ -1886,10 +1897,12 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
-  deleteUser: (id: number) =>
-    request<void>(`/users/${id}`, {
+  deleteUser: (id: number, deleteItems: boolean = false) =>
+    request<void>(`/users/${id}?delete_items=${deleteItems}`, {
       method: 'DELETE',
       method: 'DELETE',
     }),
     }),
+  getUserItemsCount: (id: number) =>
+    request<{ archives: number; queue_items: number; library_files: number }>(`/users/${id}/items-count`),
   changePassword: (currentPassword: string, newPassword: string) =>
   changePassword: (currentPassword: string, newPassword: string) =>
     request<{ message: string }>('/users/me/change-password', {
     request<{ message: string }>('/users/me/change-password', {
       method: 'POST',
       method: 'POST',
@@ -1975,6 +1988,10 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  // Get current print user (for reprint tracking - Issue #206)
+  getCurrentPrintUser: (printerId: number) =>
+    request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`),
+
   // Chamber Light Control
   // Chamber Light Control
   setChamberLight: (printerId: number, on: boolean) =>
   setChamberLight: (printerId: number, on: boolean) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
@@ -2223,8 +2240,13 @@ export const api = {
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2273,8 +2295,13 @@ export const api = {
     if (audioFile) {
     if (audioFile) {
       formData.append('audio', audioFile);
       formData.append('audio', audioFile);
     }
     }
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2289,7 +2316,12 @@ export const api = {
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
+      headers,
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
     });
     });
@@ -2311,8 +2343,13 @@ export const api = {
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2331,8 +2368,13 @@ export const api = {
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2461,8 +2503,13 @@ export const api = {
     const url = printerId
     const url = printerId
       ? `${API_BASE}/archives/upload?printer_id=${printerId}`
       ? `${API_BASE}/archives/upload?printer_id=${printerId}`
       : `${API_BASE}/archives/upload`;
       : `${API_BASE}/archives/upload`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2477,8 +2524,13 @@ export const api = {
     const url = printerId
     const url = printerId
       ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
       ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
       : `${API_BASE}/archives/upload-bulk`;
       : `${API_BASE}/archives/upload-bulk`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2498,25 +2550,9 @@ export const api = {
   getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
   getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
   resetSettings: () =>
   resetSettings: () =>
     request<AppSettings>('/settings/reset', { method: 'POST' }),
     request<AppSettings>('/settings/reset', { method: 'POST' }),
-  exportBackup: async (categories?: Record<string, boolean>): Promise<{ blob: Blob; filename: string }> => {
-    const params = new URLSearchParams();
-    if (categories) {
-      if (categories.settings !== undefined) params.set('include_settings', String(categories.settings));
-      if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));
-      if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));
-      if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));
-      if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links));
-      if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));
-      if (categories.plate_calibration !== undefined) params.set('include_plate_calibration', String(categories.plate_calibration));
-      if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));
-      if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));
-      if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));
-      if (categories.projects !== undefined) params.set('include_projects', String(categories.projects));
-      if (categories.pending_uploads !== undefined) params.set('include_pending_uploads', String(categories.pending_uploads));
-      if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));
-      if (categories.api_keys !== undefined) params.set('include_api_keys', String(categories.api_keys));
-    }
-    const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;
+  exportBackup: async (): Promise<{ blob: Blob; filename: string }> => {
+    // New simplified backup - complete database + all files
+    const url = `${API_BASE}/settings/backup`;
     const response = await fetch(url);
     const response = await fetch(url);
 
 
     // Check for errors
     // Check for errors
@@ -2527,7 +2563,7 @@ export const api = {
 
 
     // Get filename from Content-Disposition header
     // Get filename from Content-Disposition header
     const contentDisposition = response.headers.get('Content-Disposition');
     const contentDisposition = response.headers.get('Content-Disposition');
-    let filename = 'bambuddy-backup.json';
+    let filename = 'bambuddy-backup.zip';
     if (contentDisposition) {
     if (contentDisposition) {
       const match = contentDisposition.match(/filename=([^;]+)/);
       const match = contentDisposition.match(/filename=([^;]+)/);
       if (match) filename = match[1].trim();
       if (match) filename = match[1].trim();
@@ -2536,22 +2572,23 @@ export const api = {
     const blob = await response.blob();
     const blob = await response.blob();
     return { blob, filename };
     return { blob, filename };
   },
   },
-  importBackup: async (file: File, overwrite = false) => {
+  importBackup: async (file: File) => {
+    // New simplified restore - replaces database + all directories
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
-    const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;
+    const url = `${API_BASE}/settings/restore`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     return response.json() as Promise<{
     return response.json() as Promise<{
       success: boolean;
       success: boolean;
       message: string;
       message: string;
-      restored?: Record<string, number>;
-      skipped?: Record<string, number>;
-      skipped_details?: Record<string, string[]>;
-      files_restored?: number;
-      total_skipped?: number;
     }>;
     }>;
   },
   },
   checkFfmpeg: () =>
   checkFfmpeg: () =>
@@ -3047,8 +3084,13 @@ export const api = {
   uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
   uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
     const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3107,8 +3149,13 @@ export const api = {
   }> => {
   }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
     const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3225,8 +3272,13 @@ export const api = {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (folderId) params.set('folder_id', String(folderId));
     if (folderId) params.set('folder_id', String(folderId));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/library/files?${params}`, {
     const response = await fetch(`${API_BASE}/library/files?${params}`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3249,8 +3301,13 @@ export const api = {
     params.set('preserve_structure', String(preserveStructure));
     params.set('preserve_structure', String(preserveStructure));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3550,6 +3607,9 @@ export interface LibraryFile {
   notes: string | null;
   notes: string | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicate_count: number;
   duplicate_count: number;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }
@@ -3563,6 +3623,9 @@ export interface LibraryFileListItem {
   thumbnail_path: string | null;
   thumbnail_path: string | null;
   print_count: number;
   print_count: number;
   duplicate_count: number;
   duplicate_count: number;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
   created_at: string;
   created_at: string;
   print_name: string | null;
   print_name: string | null;
   print_time_seconds: number | null;
   print_time_seconds: number | null;

+ 1 - 1
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -461,7 +461,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     }
     }
   }, [isDragging, isResizing, dragOffset]);
   }, [isDragging, isResizing, dragOffset]);
 
 
-  const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`;
+  const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`;
 
 
   return (
   return (
     <div
     <div

+ 169 - 43
frontend/src/components/GitHubBackupSettings.tsx

@@ -16,6 +16,7 @@ import {
   SkipForward,
   SkipForward,
   AlertTriangle,
   AlertTriangle,
   Trash2,
   Trash2,
+  RotateCcw,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -31,8 +32,7 @@ import type {
 import { Card, CardContent, CardHeader } from './Card';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
-import { BackupModal } from './BackupModal';
-import { RestoreModal } from './RestoreModal';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 interface StatusBadgeProps {
 interface StatusBadgeProps {
@@ -108,9 +108,30 @@ export function GitHubBackupSettings() {
   const [backupSettings, setBackupSettings] = useState(false);
   const [backupSettings, setBackupSettings] = useState(false);
   const [enabled, setEnabled] = useState(true);
   const [enabled, setEnabled] = useState(true);
 
 
-  // Local backup modals
-  const [showBackupModal, setShowBackupModal] = useState(false);
-  const [showRestoreModal, setShowRestoreModal] = useState(false);
+  // Local backup state
+  const [isExporting, setIsExporting] = useState(false);
+  const [isRestoring, setIsRestoring] = useState(false);
+  const [operationStatus, setOperationStatus] = useState<string>('');
+  const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
+  const [restoreFile, setRestoreFile] = useState<File | null>(null);
+  const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // Block navigation while backup/restore is in progress
+  useEffect(() => {
+    const isOperationInProgress = isExporting || isRestoring;
+
+    if (isOperationInProgress) {
+      const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+        e.preventDefault();
+        e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
+        return e.returnValue;
+      };
+
+      window.addEventListener('beforeunload', handleBeforeUnload);
+      return () => window.removeEventListener('beforeunload', handleBeforeUnload);
+    }
+  }, [isExporting, isRestoring]);
 
 
   // Test connection state
   // Test connection state
   const [testLoading, setTestLoading] = useState(false);
   const [testLoading, setTestLoading] = useState(false);
@@ -696,80 +717,185 @@ export function GitHubBackupSettings() {
           </CardHeader>
           </CardHeader>
           <CardContent className="space-y-4">
           <CardContent className="space-y-4">
             <p className="text-sm text-bambu-gray">
             <p className="text-sm text-bambu-gray">
-              Export or import your Bambuddy data as a local file for manual backup or migration.
+              Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.
             </p>
             </p>
 
 
+            {/* Export */}
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
               <div>
               <div>
-                <p className="text-white">Export Data</p>
+                <p className="text-white">Download Backup</p>
                 <p className="text-sm text-bambu-gray">
                 <p className="text-sm text-bambu-gray">
-                  Download all settings, printers, and profiles
+                  Complete backup: database + all files (ZIP)
                 </p>
                 </p>
               </div>
               </div>
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
-                onClick={() => setShowBackupModal(true)}
+                disabled={isExporting || isRestoring}
+                onClick={async () => {
+                  setIsExporting(true);
+                  setOperationStatus('Preparing backup...');
+                  try {
+                    setOperationStatus('Creating backup archive... This may take a while for large archives.');
+                    const { blob, filename } = await api.exportBackup();
+                    setOperationStatus('Downloading backup file...');
+                    const url = URL.createObjectURL(blob);
+                    const a = document.createElement('a');
+                    a.href = url;
+                    a.download = filename;
+                    a.click();
+                    URL.revokeObjectURL(url);
+                    showToast('Backup downloaded successfully');
+                  } catch (e) {
+                    showToast(`Failed to create backup: ${e instanceof Error ? e.message : 'Unknown error'}`, 'error');
+                  } finally {
+                    setIsExporting(false);
+                    setOperationStatus('');
+                  }
+                }}
               >
               >
                 <Download className="w-4 h-4" />
                 <Download className="w-4 h-4" />
-                Export
+                Download
               </Button>
               </Button>
             </div>
             </div>
 
 
-            <div className="flex items-center justify-between py-3">
+            {/* Import */}
+            <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
               <div>
               <div>
-                <p className="text-white">Import Backup</p>
+                <p className="text-white">Restore Backup</p>
                 <p className="text-sm text-bambu-gray">
                 <p className="text-sm text-bambu-gray">
-                  Restore from a previous export file
+                  Replace all data from a backup file
                 </p>
                 </p>
               </div>
               </div>
+              <input
+                ref={fileInputRef}
+                type="file"
+                accept=".zip"
+                className="hidden"
+                onChange={(e) => {
+                  const file = e.target.files?.[0];
+                  if (file) {
+                    setRestoreFile(file);
+                    setShowRestoreConfirm(true);
+                  }
+                  e.target.value = '';
+                }}
+              />
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
-                onClick={() => setShowRestoreModal(true)}
+                disabled={isRestoring || isExporting}
+                onClick={() => fileInputRef.current?.click()}
               >
               >
                 <Upload className="w-4 h-4" />
                 <Upload className="w-4 h-4" />
-                Import
+                Restore
               </Button>
               </Button>
             </div>
             </div>
+
+            {/* Restore result message */}
+            {restoreResult && (
+              <div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
+                <div className="flex items-start gap-2 text-sm">
+                  {restoreResult.success ? (
+                    <CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
+                  ) : (
+                    <XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
+                  )}
+                  <div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
+                    {restoreResult.message}
+                    {restoreResult.success && (
+                      <div className="mt-2">
+                        <Button
+                          size="sm"
+                          onClick={() => window.location.reload()}
+                        >
+                          <RotateCcw className="w-3 h-3" />
+                          Reload Now
+                        </Button>
+                      </div>
+                    )}
+                  </div>
+                </div>
+              </div>
+            )}
+
+            {/* Warning */}
+            <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+              <div className="flex items-start gap-2 text-sm">
+                <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
+                <div className="text-yellow-200">
+                  <span className="font-medium">Restore replaces all data.</span>{' '}
+                  <span className="text-yellow-200/70">Your current database and files will be completely replaced. A restart is required after restore.</span>
+                </div>
+              </div>
+            </div>
           </CardContent>
           </CardContent>
         </Card>
         </Card>
       </div>
       </div>
 
 
-      {/* Modals */}
-      {showBackupModal && (
-        <BackupModal
-          onClose={() => setShowBackupModal(false)}
-          onExport={async (categories) => {
-            setShowBackupModal(false);
+      {/* Restore Confirmation Modal */}
+      {showRestoreConfirm && restoreFile && (
+        <ConfirmModal
+          title="Restore Backup"
+          message={`Are you sure you want to restore from "${restoreFile.name}"? This will completely replace your current database and all files. The application will need to be restarted after restore.`}
+          confirmText="Restore Backup"
+          variant="danger"
+          onConfirm={async () => {
+            setShowRestoreConfirm(false);
+            setIsRestoring(true);
+            setRestoreResult(null);
             try {
             try {
-              const { blob, filename } = await api.exportBackup(categories);
-              const url = URL.createObjectURL(blob);
-              const a = document.createElement('a');
-              a.href = url;
-              a.download = filename;
-              a.click();
-              URL.revokeObjectURL(url);
-              showToast('Backup downloaded successfully');
-            } catch {
-              showToast('Failed to create backup', 'error');
+              setOperationStatus('Uploading backup file...');
+              const result = await api.importBackup(restoreFile);
+              setRestoreResult(result);
+              if (result.success) {
+                showToast('Backup restored. Please restart Bambuddy.', 'success');
+              } else {
+                showToast(result.message, 'error');
+              }
+            } catch (e) {
+              const message = e instanceof Error ? e.message : 'Failed to restore backup';
+              setRestoreResult({ success: false, message });
+              showToast(message, 'error');
+            } finally {
+              setIsRestoring(false);
+              setOperationStatus('');
+              setRestoreFile(null);
             }
             }
           }}
           }}
+          onCancel={() => {
+            setShowRestoreConfirm(false);
+            setRestoreFile(null);
+          }}
         />
         />
       )}
       )}
 
 
-      {showRestoreModal && (
-        <RestoreModal
-          onClose={() => setShowRestoreModal(false)}
-          onRestore={async (file, overwrite) => {
-            return await api.importBackup(file, overwrite);
-          }}
-          onSuccess={() => {
-            setShowRestoreModal(false);
-            showToast('Backup restored successfully');
-            queryClient.invalidateQueries();
-          }}
-        />
+      {/* Blocking overlay during backup/restore operations */}
+      {(isExporting || isRestoring) && (
+        <div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
+          <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
+            <div className="flex justify-center mb-4">
+              <div className="relative">
+                <div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
+                <div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
+              </div>
+            </div>
+            <h3 className="text-xl font-semibold text-white mb-2">
+              {isExporting ? 'Creating Backup' : 'Restoring Backup'}
+            </h3>
+            <p className="text-bambu-gray mb-4">
+              {operationStatus || (isExporting ? 'Preparing...' : 'Processing...')}
+            </p>
+            <div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+              <div className="flex items-start gap-2 text-sm">
+                <AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
+                <p className="text-yellow-200 text-left">
+                  Please do not close this page or navigate away. This operation may take several minutes for large backups.
+                </p>
+              </div>
+            </div>
+          </div>
+        </div>
       )}
       )}
     </div>
     </div>
   );
   );

+ 27 - 0
frontend/src/contexts/AuthContext.tsx

@@ -15,6 +15,7 @@ interface AuthContextType {
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
   hasAnyPermission: (...permissions: Permission[]) => boolean;
   hasAnyPermission: (...permissions: Permission[]) => boolean;
   hasAllPermissions: (...permissions: Permission[]) => boolean;
   hasAllPermissions: (...permissions: Permission[]) => boolean;
+  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
 }
 }
 
 
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -156,6 +157,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     return permissions.every(p => permissionSet.has(p));
     return permissions.every(p => permissionSet.has(p));
   }, [authEnabled, isAdmin, permissionSet]);
   }, [authEnabled, isAdmin, permissionSet]);
 
 
+  // Ownership-based permission check
+  const canModify = useCallback((
+    resource: 'queue' | 'archives' | 'library',
+    action: 'update' | 'delete' | 'reprint',
+    createdById: number | null | undefined,
+  ): boolean => {
+    if (!authEnabled) return true;  // Auth disabled, allow all
+    if (isAdmin) return true;  // Admins can modify anything
+
+    const allPerm = `${resource}:${action}_all` as Permission;
+    const ownPerm = `${resource}:${action}_own` as Permission;
+
+    // User has *_all permission - can modify any item
+    if (permissionSet.has(allPerm)) return true;
+
+    // User has *_own permission - can only modify their own items
+    if (permissionSet.has(ownPerm)) {
+      // Ownerless items (null created_by_id) require *_all permission
+      if (createdById == null) return false;
+      return createdById === user?.id;
+    }
+
+    return false;
+  }, [authEnabled, isAdmin, permissionSet, user?.id]);
+
   return (
   return (
     <AuthContext.Provider
     <AuthContext.Provider
       value={{
       value={{
@@ -171,6 +197,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         hasPermission,
         hasPermission,
         hasAnyPermission,
         hasAnyPermission,
         hasAllPermissions,
         hasAllPermissions,
+        canModify,
       }}
       }}
     >
     >
       {children}
       {children}

+ 99 - 73
frontend/src/pages/ArchivesPage.tsx

@@ -44,6 +44,7 @@ import {
   ChevronLeft,
   ChevronLeft,
   ChevronRight,
   ChevronRight,
   Settings,
   Settings,
+  User,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
@@ -123,7 +124,7 @@ function ArchiveCard({
 
 
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -286,8 +287,8 @@ function ArchiveCard({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
-        disabled: !hasPermission('archives:reprint'),
-        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
+        disabled: !canModify('archives', 'reprint', archive.created_by_id),
+        title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
@@ -341,8 +342,8 @@ function ArchiveCard({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       onClick: () => timelapseScanMutation.mutate(),
-      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined,
     },
     },
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -358,30 +359,30 @@ function ArchiveCard({
           source3mfInputRef.current?.click();
           source3mfInputRef.current?.click();
         }
         }
       },
       },
-      disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
-      title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
+      disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),
+      title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined,
     },
     },
     ...(archive.source_3mf_path ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       onClick: () => source3mfInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Remove Source 3MF',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     {
     {
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -398,8 +399,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -449,15 +450,15 @@ function ArchiveCard({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -468,8 +469,8 @@ function ArchiveCard({
       label: 'Add to Project',
       label: 'Add to Project',
       icon: <FolderKanban className="w-4 h-4" />,
       icon: <FolderKanban className="w-4 h-4" />,
       onClick: () => {},
       onClick: () => {},
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
       submenu: (() => {
       submenu: (() => {
         const items: ContextMenuItem[] = [];
         const items: ContextMenuItem[] = [];
 
 
@@ -479,7 +480,7 @@ function ArchiveCard({
             label: 'Remove from Project',
             label: 'Remove from Project',
             icon: <X className="w-4 h-4" />,
             icon: <X className="w-4 h-4" />,
             onClick: () => assignProjectMutation.mutate(null),
             onClick: () => assignProjectMutation.mutate(null),
-            disabled: !hasPermission('archives:update'),
+            disabled: !canModify('archives', 'update', archive.created_by_id),
           });
           });
         }
         }
 
 
@@ -506,7 +507,7 @@ function ArchiveCard({
                 label: p.name,
                 label: p.name,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 onClick: () => assignProjectMutation.mutate(p.id),
                 onClick: () => assignProjectMutation.mutate(p.id),
-                disabled: archive.project_id === p.id || !hasPermission('archives:update'),
+                disabled: archive.project_id === p.id || !canModify('archives', 'update', archive.created_by_id),
               });
               });
             });
             });
           }
           }
@@ -526,8 +527,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:delete'),
-      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
+      disabled: !canModify('archives', 'delete', archive.created_by_id),
+      title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined,
     },
     },
   ];
   ];
 
 
@@ -648,21 +649,21 @@ function ArchiveCard({
         {/* Favorite star */}
         {/* Favorite star */}
         <button
         <button
           className={`absolute top-2 right-2 p-1 rounded transition-colors ${
           className={`absolute top-2 right-2 p-1 rounded transition-colors ${
-            hasPermission('archives:update')
+            canModify('archives', 'update', archive.created_by_id)
               ? 'bg-black/50 hover:bg-black/70'
               ? 'bg-black/50 hover:bg-black/70'
               : 'bg-black/30 cursor-not-allowed'
               : 'bg-black/30 cursor-not-allowed'
           }`}
           }`}
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
-            if (hasPermission('archives:update')) {
+            if (canModify('archives', 'update', archive.created_by_id)) {
               favoriteMutation.mutate();
               favoriteMutation.mutate();
             }
             }
           }}
           }}
-          disabled={!hasPermission('archives:update')}
-          title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
+          disabled={!canModify('archives', 'update', archive.created_by_id)}
+          title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
         >
         >
           <Star
           <Star
-            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!hasPermission('archives:update') ? 'opacity-50' : ''}`}
+            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!canModify('archives', 'update', archive.created_by_id) ? 'opacity-50' : ''}`}
           />
           />
         </button>
         </button>
         {(archive.status === 'failed' || archive.status === 'aborted') && (
         {(archive.status === 'failed' || archive.status === 'aborted') && (
@@ -886,10 +887,18 @@ function ArchiveCard({
         {/* Spacer to push content to bottom */}
         {/* Spacer to push content to bottom */}
         <div className="flex-1" />
         <div className="flex-1" />
 
 
-        {/* Date & Size */}
+        {/* Date, Size & Creator */}
         <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
         <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
           <span>{formatDateTime(archive.created_at, timeFormat)}</span>
           <span>{formatDateTime(archive.created_at, timeFormat)}</span>
-          <span>{formatFileSize(archive.file_size)}</span>
+          <div className="flex items-center gap-2">
+            {archive.created_by_username && (
+              <span className="flex items-center gap-1" title={`Uploaded by ${archive.created_by_username}`}>
+                <User className="w-3 h-3" />
+                {archive.created_by_username}
+              </span>
+            )}
+            <span>{formatFileSize(archive.file_size)}</span>
+          </div>
         </div>
         </div>
 
 
         {/* Actions */}
         {/* Actions */}
@@ -902,12 +911,23 @@ function ArchiveCard({
                 size="sm"
                 size="sm"
                 className="flex-1 min-w-0"
                 className="flex-1 min-w-0"
                 onClick={() => setShowReprint(true)}
                 onClick={() => setShowReprint(true)}
-                disabled={!hasPermission('archives:reprint')}
-                title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined}
+                disabled={!canModify('archives', 'reprint', archive.created_by_id)}
+                title={!canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint' : undefined}
               >
               >
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <span className="hidden sm:inline">Reprint</span>
                 <span className="hidden sm:inline">Reprint</span>
               </Button>
               </Button>
+              <Button
+                variant="secondary"
+                size="sm"
+                className="flex-1 min-w-0"
+                onClick={() => setShowSchedule(true)}
+                disabled={!hasPermission('queue:create')}
+                title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : 'Schedule Print'}
+              >
+                <Calendar className="w-3 h-3 flex-shrink-0" />
+                <span className="hidden sm:inline">Schedule</span>
+              </Button>
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
@@ -986,8 +1006,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
+            disabled={!canModify('archives', 'update', archive.created_by_id)}
+            title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
           </Button>
           </Button>
@@ -996,8 +1016,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            disabled={!hasPermission('archives:delete')}
-            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
+            disabled={!canModify('archives', 'delete', archive.created_by_id)}
+            title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
           </Button>
           </Button>
@@ -1254,7 +1274,7 @@ function ArchiveListRow({
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -1399,8 +1419,8 @@ function ArchiveListRow({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
-        disabled: !hasPermission('archives:reprint'),
-        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
+        disabled: !canModify('archives', 'reprint', archive.created_by_id),
+        title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
@@ -1454,8 +1474,8 @@ function ArchiveListRow({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       onClick: () => timelapseScanMutation.mutate(),
-      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined,
     },
     },
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1471,30 +1491,30 @@ function ArchiveListRow({
           source3mfInputRef.current?.click();
           source3mfInputRef.current?.click();
         }
         }
       },
       },
-      disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
-      title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
+      disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),
+      title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined,
     },
     },
     ...(archive.source_3mf_path ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       onClick: () => source3mfInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Remove Source 3MF',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     {
     {
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -1511,8 +1531,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1562,15 +1582,15 @@ function ArchiveListRow({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -1631,8 +1651,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:delete'),
-      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
+      disabled: !canModify('archives', 'delete', archive.created_by_id),
+      title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined,
     },
     },
   ];
   ];
 
 
@@ -1720,7 +1740,13 @@ function ArchiveListRow({
           {printerName}
           {printerName}
         </div>
         </div>
         <div className="col-span-2 text-sm text-bambu-gray">
         <div className="col-span-2 text-sm text-bambu-gray">
-          {formatDateOnly(archive.created_at)}
+          <div>{formatDateOnly(archive.created_at)}</div>
+          {archive.created_by_username && (
+            <div className="flex items-center gap-1 text-xs opacity-75" title={`Uploaded by ${archive.created_by_username}`}>
+              <User className="w-3 h-3" />
+              {archive.created_by_username}
+            </div>
+          )}
         </div>
         </div>
         <div className="col-span-1 text-sm text-bambu-gray">
         <div className="col-span-1 text-sm text-bambu-gray">
           {formatFileSize(archive.file_size)}
           {formatFileSize(archive.file_size)}
@@ -1765,8 +1791,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
+            disabled={!canModify('archives', 'update', archive.created_by_id)}
+            title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-4 h-4" />
             <Pencil className="w-4 h-4" />
           </Button>
           </Button>
@@ -1774,8 +1800,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            disabled={!hasPermission('archives:delete')}
-            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
+            disabled={!canModify('archives', 'delete', archive.created_by_id)}
+            title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-4 h-4 text-red-400" />
             <Trash2 className="w-4 h-4 text-red-400" />
           </Button>
           </Button>
@@ -2029,7 +2055,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 export function ArchivesPage() {
 export function ArchivesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission } = useAuth();
   const searchInputRef = useRef<HTMLInputElement>(null);
   const searchInputRef = useRef<HTMLInputElement>(null);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
@@ -2445,8 +2471,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchTag(true)}
             onClick={() => setShowBatchTag(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
+            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
+            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <Tag className="w-4 h-4" />
             <Tag className="w-4 h-4" />
             Tags
             Tags
@@ -2455,8 +2481,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchProject(true)}
             onClick={() => setShowBatchProject(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
+            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
+            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <FolderKanban className="w-4 h-4" />
             <FolderKanban className="w-4 h-4" />
             Project
             Project
@@ -2464,8 +2490,8 @@ export function ArchivesPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
+            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
+            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined}
             onClick={() => {
             onClick={() => {
               const ids = Array.from(selectedIds);
               const ids = Array.from(selectedIds);
               Promise.all(ids.map(id => api.toggleFavorite(id)))
               Promise.all(ids.map(id => api.toggleFavorite(id)))
@@ -2485,8 +2511,8 @@ export function ArchivesPage() {
             size="sm"
             size="sm"
             className="bg-red-500 hover:bg-red-600"
             className="bg-red-500 hover:bg-red-600"
             onClick={() => setShowBulkDeleteConfirm(true)}
             onClick={() => setShowBulkDeleteConfirm(true)}
-            disabled={!hasPermission('archives:delete')}
-            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined}
+            disabled={!hasAnyPermission('archives:delete_own', 'archives:delete_all')}
+            title={!hasAnyPermission('archives:delete_own', 'archives:delete_all') ? 'You do not have permission to delete archives' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Delete
             Delete

+ 1 - 1
frontend/src/pages/CameraPage.tsx

@@ -531,7 +531,7 @@ export function CameraPage() {
   const currentUrl = transitioning
   const currentUrl = transitioning
     ? ''
     ? ''
     : streamMode === 'stream'
     : streamMode === 'stream'
-      ? `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`
+      ? `/api/v1/printers/${id}/camera/stream?fps=15&t=${imageKey}`
       : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
       : `/api/v1/printers/${id}/camera/snapshot?t=${imageKey}`;
 
 
   const isDisabled = streamLoading || transitioning || isReconnecting;
   const isDisabled = streamLoading || transitioning || isReconnecting;

+ 51 - 44
frontend/src/pages/FileManagerPage.tsx

@@ -803,33 +803,33 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   }`}
-                  onClick={() => { if (hasPermission('library:update')) { onRename(folder); setShowActions(false); } }}
-                  disabled={!hasPermission('library:update')}
-                  title={!hasPermission('library:update') ? 'You do not have permission to rename folders' : undefined}
+                  onClick={() => { if (hasPermission('library:update_all')) { onRename(folder); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update_all')}
+                  title={!hasPermission('library:update_all') ? 'You do not have permission to rename folders' : undefined}
                 >
                 >
                   <Pencil className="w-3.5 h-3.5" />
                   <Pencil className="w-3.5 h-3.5" />
                   Rename
                   Rename
                 </button>
                 </button>
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    hasPermission('library:update_all') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   }`}
-                  onClick={() => { if (hasPermission('library:update')) { onLink(folder); setShowActions(false); } }}
-                  disabled={!hasPermission('library:update')}
-                  title={!hasPermission('library:update') ? 'You do not have permission to link folders' : undefined}
+                  onClick={() => { if (hasPermission('library:update_all')) { onLink(folder); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update_all')}
+                  title={!hasPermission('library:update_all') ? 'You do not have permission to link folders' : undefined}
                 >
                 >
                   <Link2 className="w-3.5 h-3.5" />
                   <Link2 className="w-3.5 h-3.5" />
                   {isLinked ? 'Change Link...' : 'Link to...'}
                   {isLinked ? 'Change Link...' : 'Link to...'}
                 </button>
                 </button>
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    hasPermission('library:delete_all') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   }`}
-                  onClick={() => { if (hasPermission('library:delete')) { onDelete(folder.id); setShowActions(false); } }}
-                  disabled={!hasPermission('library:delete')}
-                  title={!hasPermission('library:delete') ? 'You do not have permission to delete folders' : undefined}
+                  onClick={() => { if (hasPermission('library:delete_all')) { onDelete(folder.id); setShowActions(false); } }}
+                  disabled={!hasPermission('library:delete_all')}
+                  title={!hasPermission('library:delete_all') ? 'You do not have permission to delete folders' : undefined}
                 >
                 >
                   <Trash2 className="w-3.5 h-3.5" />
                   <Trash2 className="w-3.5 h-3.5" />
                   Delete
                   Delete
@@ -882,9 +882,10 @@ interface FileCardProps {
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
   thumbnailVersion?: number;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
+  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
 }
 }
 
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission, canModify }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -937,6 +938,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             Printed {file.print_count}x
             Printed {file.print_count}x
           </div>
           </div>
         )}
         )}
+        {file.created_by_username && (
+          <div className="mt-1 text-xs text-bambu-gray">
+            Uploaded by {file.created_by_username}
+          </div>
+        )}
       </div>
       </div>
 
 
       {/* Actions - always visible on mobile, hover on desktop */}
       {/* Actions - always visible on mobile, hover on desktop */}
@@ -991,11 +997,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               {onRename && (
               {onRename && (
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   }`}
-                  onClick={() => { if (hasPermission('library:update')) { onRename(file); setShowActions(false); } }}
-                  disabled={!hasPermission('library:update')}
-                  title={!hasPermission('library:update') ? 'You do not have permission to rename files' : undefined}
+                  onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onRename(file); setShowActions(false); } }}
+                  disabled={!canModify('library', 'update', file.created_by_id)}
+                  title={!canModify('library', 'update', file.created_by_id) ? 'You do not have permission to rename this file' : undefined}
                 >
                 >
                   <Pencil className="w-3.5 h-3.5" />
                   <Pencil className="w-3.5 h-3.5" />
                   Rename
                   Rename
@@ -1004,11 +1010,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               {onGenerateThumbnail && file.file_type === 'stl' && (
               {onGenerateThumbnail && file.file_type === 'stl' && (
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canModify('library', 'update', file.created_by_id) ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   }`}
-                  onClick={() => { if (hasPermission('library:update')) { onGenerateThumbnail(file); setShowActions(false); } }}
-                  disabled={!hasPermission('library:update')}
-                  title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : undefined}
+                  onClick={() => { if (canModify('library', 'update', file.created_by_id)) { onGenerateThumbnail(file); setShowActions(false); } }}
+                  disabled={!canModify('library', 'update', file.created_by_id)}
+                  title={!canModify('library', 'update', file.created_by_id) ? 'You do not have permission to generate thumbnails' : undefined}
                 >
                 >
                   <Image className="w-3.5 h-3.5" />
                   <Image className="w-3.5 h-3.5" />
                   Generate Thumbnail
                   Generate Thumbnail
@@ -1016,11 +1022,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               )}
               )}
               <button
               <button
                 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                  hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  canModify('library', 'delete', file.created_by_id) ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                 }`}
                 }`}
-                onClick={() => { if (hasPermission('library:delete')) { onDelete(file.id); setShowActions(false); } }}
-                disabled={!hasPermission('library:delete')}
-                title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
+                onClick={() => { if (canModify('library', 'delete', file.created_by_id)) { onDelete(file.id); setShowActions(false); } }}
+                disabled={!canModify('library', 'delete', file.created_by_id)}
+                title={!canModify('library', 'delete', file.created_by_id) ? 'You do not have permission to delete this file' : undefined}
               >
               >
                 <Trash2 className="w-3.5 h-3.5" />
                 <Trash2 className="w-3.5 h-3.5" />
                 Delete
                 Delete
@@ -1045,7 +1051,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
 export function FileManagerPage() {
 export function FileManagerPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [searchParams] = useSearchParams();
   const [searchParams] = useSearchParams();
 
 
   // Read folder ID from URL query parameter
   // Read folder ID from URL query parameter
@@ -1512,8 +1518,8 @@ export function FileManagerPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => batchThumbnailMutation.mutate()}
             onClick={() => batchThumbnailMutation.mutate()}
-            disabled={batchThumbnailMutation.isPending || !hasPermission('library:update')}
-            title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
+            disabled={batchThumbnailMutation.isPending || !hasAnyPermission('library:update_own', 'library:update_all')}
+            title={!hasAnyPermission('library:update_own', 'library:update_all') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
           >
           >
             {batchThumbnailMutation.isPending ? (
             {batchThumbnailMutation.isPending ? (
               <Loader2 className="w-4 h-4 mr-2 animate-spin" />
               <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -1827,8 +1833,8 @@ export function FileManagerPage() {
                       variant="secondary"
                       variant="secondary"
                       size="sm"
                       size="sm"
                       onClick={() => setShowMoveModal(true)}
                       onClick={() => setShowMoveModal(true)}
-                      disabled={!hasPermission('library:update')}
-                      title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined}
+                      disabled={!hasAnyPermission('library:update_own', 'library:update_all')}
+                      title={!hasAnyPermission('library:update_own', 'library:update_all') ? 'You do not have permission to move files' : undefined}
                     >
                     >
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">Move</span>
                       <span className="hidden sm:inline">Move</span>
@@ -1843,8 +1849,8 @@ export function FileManagerPage() {
                           setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
                           setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
                         }
                         }
                       }}
                       }}
-                      disabled={!hasPermission('library:delete')}
-                      title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
+                      disabled={!hasAnyPermission('library:delete_own', 'library:delete_all')}
+                      title={!hasAnyPermission('library:delete_own', 'library:delete_all') ? 'You do not have permission to delete files' : undefined}
                     >
                     >
                       <Trash2 className="w-4 h-4 sm:mr-1" />
                       <Trash2 className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">Delete</span>
                       <span className="hidden sm:inline">Delete</span>
@@ -1924,6 +1930,7 @@ export function FileManagerPage() {
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -2048,40 +2055,40 @@ export function FileManagerPage() {
                         <Download className="w-4 h-4" />
                         <Download className="w-4 h-4" />
                       </button>
                       </button>
                       <button
                       <button
-                        onClick={() => hasPermission('library:update') && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
+                        onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
                         className={`p-1.5 rounded transition-colors ${
                         className={`p-1.5 rounded transition-colors ${
-                          hasPermission('library:update')
+                          canModify('library', 'update', file.created_by_id)
                             ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                             ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                             : 'text-bambu-gray/50 cursor-not-allowed'
                             : 'text-bambu-gray/50 cursor-not-allowed'
                         }`}
                         }`}
-                        title={hasPermission('library:update') ? 'Rename' : 'You do not have permission to rename files'}
-                        disabled={!hasPermission('library:update')}
+                        title={canModify('library', 'update', file.created_by_id) ? 'Rename' : 'You do not have permission to rename this file'}
+                        disabled={!canModify('library', 'update', file.created_by_id)}
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
                       {file.file_type === 'stl' && (
                       {file.file_type === 'stl' && (
                         <button
                         <button
-                          onClick={() => hasPermission('library:update') && singleThumbnailMutation.mutate(file.id)}
+                          onClick={() => canModify('library', 'update', file.created_by_id) && singleThumbnailMutation.mutate(file.id)}
                           className={`p-1.5 rounded transition-colors ${
                           className={`p-1.5 rounded transition-colors ${
-                            hasPermission('library:update')
+                            canModify('library', 'update', file.created_by_id)
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                           }`}
                           }`}
-                          title={hasPermission('library:update') ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
-                          disabled={singleThumbnailMutation.isPending || !hasPermission('library:update')}
+                          title={canModify('library', 'update', file.created_by_id) ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
+                          disabled={singleThumbnailMutation.isPending || !canModify('library', 'update', file.created_by_id)}
                         >
                         >
                           <Image className="w-4 h-4" />
                           <Image className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
                       <button
                       <button
-                        onClick={() => hasPermission('library:delete') && setDeleteConfirm({ type: 'file', id: file.id })}
+                        onClick={() => canModify('library', 'delete', file.created_by_id) && setDeleteConfirm({ type: 'file', id: file.id })}
                         className={`p-1.5 rounded transition-colors ${
                         className={`p-1.5 rounded transition-colors ${
-                          hasPermission('library:delete')
+                          canModify('library', 'delete', file.created_by_id)
                             ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
                             ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
                             : 'text-bambu-gray/50 cursor-not-allowed'
                             : 'text-bambu-gray/50 cursor-not-allowed'
                         }`}
                         }`}
-                        title={hasPermission('library:delete') ? 'Delete' : 'You do not have permission to delete files'}
-                        disabled={!hasPermission('library:delete')}
+                        title={canModify('library', 'delete', file.created_by_id) ? 'Delete' : 'You do not have permission to delete this file'}
+                        disabled={!canModify('library', 'delete', file.created_by_id)}
                       >
                       >
                         <Trash2 className="w-4 h-4" />
                         <Trash2 className="w-4 h-4" />
                       </button>
                       </button>

+ 32 - 8
frontend/src/pages/PrintersPage.tsx

@@ -40,6 +40,7 @@ import {
   ScanSearch,
   ScanSearch,
   CheckCircle,
   CheckCircle,
   XCircle,
   XCircle,
+  User,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 // Custom Skip Objects icon - arrow jumping over boxes
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -1116,6 +1117,23 @@ function PrinterCard({
   });
   });
   const queueCount = queueItems?.length || 0;
   const queueCount = queueItems?.length || 0;
 
 
+  // Fetch currently printing queue item to show who started it (Issue #206)
+  const { data: printingQueueItems } = useQuery({
+    queryKey: ['queue', printer.id, 'printing'],
+    queryFn: () => api.getQueue(printer.id, 'printing'),
+    enabled: status?.state === 'RUNNING',
+  });
+
+  // Fetch reprint user info (for prints started via Reprint, not queue - Issue #206)
+  const { data: reprintUser } = useQuery({
+    queryKey: ['currentPrintUser', printer.id],
+    queryFn: () => api.getCurrentPrintUser(printer.id),
+    enabled: status?.state === 'RUNNING',
+  });
+
+  // Combine both sources: queue item user takes precedence, then reprint user
+  const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username;
+
   // Fetch last completed print for this printer
   // Fetch last completed print for this printer
   const { data: lastPrints } = useQuery({
   const { data: lastPrints } = useQuery({
     queryKey: ['archives', printer.id, 'last'],
     queryKey: ['archives', printer.id, 'last'],
@@ -1896,6 +1914,12 @@ function PrinterCard({
                                 {status.layer_num}/{status.total_layers}
                                 {status.layer_num}/{status.total_layers}
                               </span>
                               </span>
                             )}
                             )}
+                            {currentPrintUser && (
+                              <span className="flex items-center gap-1" title={`Started by ${currentPrintUser}`}>
+                                <User className="w-3 h-3" />
+                                {currentPrintUser}
+                              </span>
+                            )}
                           </div>
                           </div>
                         </>
                         </>
                       ) : (
                       ) : (
@@ -2267,18 +2291,18 @@ function PrinterCard({
                                       <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                       <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                         <button
                                         <button
                                           className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
                                           className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
-                                            hasPermission('printers:control')
+                                            hasPermission('printers:ams_rfid')
                                               ? 'text-white hover:bg-bambu-dark-tertiary'
                                               ? 'text-white hover:bg-bambu-dark-tertiary'
                                               : 'text-bambu-gray/50 cursor-not-allowed'
                                               : 'text-bambu-gray/50 cursor-not-allowed'
                                           }`}
                                           }`}
                                           onClick={(e) => {
                                           onClick={(e) => {
                                             e.stopPropagation();
                                             e.stopPropagation();
-                                            if (!hasPermission('printers:control')) return;
+                                            if (!hasPermission('printers:ams_rfid')) return;
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             setAmsSlotMenu(null);
                                             setAmsSlotMenu(null);
                                           }}
                                           }}
-                                          disabled={isRefreshing || !hasPermission('printers:control')}
-                                          title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
+                                          disabled={isRefreshing || !hasPermission('printers:ams_rfid')}
+                                          title={!hasPermission('printers:ams_rfid') ? 'You do not have permission to re-read AMS RFID' : undefined}
                                         >
                                         >
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           Re-read RFID
                                           Re-read RFID
@@ -2456,18 +2480,18 @@ function PrinterCard({
                                   <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                   <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                     <button
                                     <button
                                       className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
                                       className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
-                                        hasPermission('printers:control')
+                                        hasPermission('printers:ams_rfid')
                                           ? 'text-white hover:bg-bambu-dark-tertiary'
                                           ? 'text-white hover:bg-bambu-dark-tertiary'
                                           : 'text-bambu-gray/50 cursor-not-allowed'
                                           : 'text-bambu-gray/50 cursor-not-allowed'
                                       }`}
                                       }`}
                                       onClick={(e) => {
                                       onClick={(e) => {
                                         e.stopPropagation();
                                         e.stopPropagation();
-                                        if (!hasPermission('printers:control')) return;
+                                        if (!hasPermission('printers:ams_rfid')) return;
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         setAmsSlotMenu(null);
                                         setAmsSlotMenu(null);
                                       }}
                                       }}
-                                      disabled={isHtRefreshing || !hasPermission('printers:control')}
-                                      title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
+                                      disabled={isHtRefreshing || !hasPermission('printers:ams_rfid')}
+                                      title={!hasPermission('printers:ams_rfid') ? 'You do not have permission to re-read AMS RFID' : undefined}
                                     >
                                     >
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       Re-read RFID
                                       Re-read RFID

+ 25 - 13
frontend/src/pages/QueuePage.tsx

@@ -44,6 +44,7 @@ import {
   Check,
   Check,
   CheckSquare,
   CheckSquare,
   Square,
   Square,
+  User,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -279,6 +280,7 @@ function SortableQueueItem({
   isSelected = false,
   isSelected = false,
   onToggleSelect,
   onToggleSelect,
   hasPermission,
   hasPermission,
+  canModify,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
   position?: number;
   position?: number;
@@ -292,6 +294,7 @@ function SortableQueueItem({
   isSelected?: boolean;
   isSelected?: boolean;
   onToggleSelect?: () => void;
   onToggleSelect?: () => void;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
+  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
 }) {
 }) {
   const canReorder = hasPermission('queue:reorder');
   const canReorder = hasPermission('queue:reorder');
   const {
   const {
@@ -427,6 +430,12 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
                 {formatDuration(item.print_time_seconds)}
               </span>
               </span>
             )}
             )}
+            {item.created_by_username && (
+              <span className="flex items-center gap-1.5" title={`Added by ${item.created_by_username}`}>
+                <User className="w-3.5 h-3.5" />
+                {item.created_by_username}
+              </span>
+            )}
             {isPending && !item.manual_start && (
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
                 <Clock className="w-3.5 h-3.5" />
@@ -518,8 +527,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onEdit}
                 onClick={onEdit}
-                disabled={!hasPermission('queue:update')}
-                title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : 'Edit'}
+                disabled={!canModify('queue', 'update', item.created_by_id)}
+                title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'}
               >
               >
                 <Pencil className="w-4 h-4" />
                 <Pencil className="w-4 h-4" />
               </Button>
               </Button>
@@ -527,8 +536,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onCancel}
                 onClick={onCancel}
-                disabled={!hasPermission('queue:delete')}
-                title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : 'Cancel'}
+                disabled={!canModify('queue', 'delete', item.created_by_id)}
+                title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'}
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               >
               >
                 <X className="w-4 h-4" />
                 <X className="w-4 h-4" />
@@ -551,8 +560,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onRemove}
                 onClick={onRemove}
-                disabled={!hasPermission('queue:delete')}
-                title={!hasPermission('queue:delete') ? 'You do not have permission to remove queue items' : 'Remove'}
+                disabled={!canModify('queue', 'delete', item.created_by_id)}
+                title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'}
               >
               >
                 <Trash2 className="w-4 h-4" />
                 <Trash2 className="w-4 h-4" />
               </Button>
               </Button>
@@ -567,7 +576,7 @@ function SortableQueueItem({
 export function QueuePage() {
 export function QueuePage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
@@ -921,8 +930,8 @@ export function QueuePage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
             onClick={() => setShowClearHistoryConfirm(true)}
-            disabled={!hasPermission('queue:delete')}
-            title={!hasPermission('queue:delete') ? 'You do not have permission to clear history' : undefined}
+            disabled={!hasPermission('queue:delete_all')}
+            title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Clear History
             Clear History
@@ -963,6 +972,7 @@ export function QueuePage() {
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1032,8 +1042,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
                       onClick={() => setShowBulkEditModal(true)}
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
-                      disabled={!hasPermission('queue:update')}
-                      title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : undefined}
+                      disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
+                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined}
                     >
                     >
                       <Pencil className="w-4 h-4" />
                       <Pencil className="w-4 h-4" />
                       Edit Selected
                       Edit Selected
@@ -1043,8 +1053,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
-                      disabled={bulkCancelMutation.isPending || !hasPermission('queue:delete')}
-                      title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : undefined}
+                      disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
+                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined}
                     >
                     >
                       <X className="w-4 h-4" />
                       <X className="w-4 h-4" />
                       Cancel Selected
                       Cancel Selected
@@ -1078,6 +1088,7 @@ export function QueuePage() {
                         isSelected={selectedItems.includes(item.id)}
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         hasPermission={hasPermission}
                         hasPermission={hasPermission}
+                        canModify={canModify}
                       />
                       />
                     ))}
                     ))}
                   </div>
                   </div>
@@ -1132,6 +1143,7 @@ export function QueuePage() {
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>

+ 114 - 11
frontend/src/pages/SettingsPage.tsx

@@ -95,6 +95,8 @@ export function SettingsPage() {
   const [showEditUserModal, setShowEditUserModal] = useState(false);
   const [showEditUserModal, setShowEditUserModal] = useState(false);
   const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
+  const [deleteUserItemCounts, setDeleteUserItemCounts] = useState<{ archives: number; queue_items: number; library_files: number } | null>(null);
+  const [deleteUserLoading, setDeleteUserLoading] = useState(false);
   const [userFormData, setUserFormData] = useState<{
   const [userFormData, setUserFormData] = useState<{
     username: string;
     username: string;
     password: string;
     password: string;
@@ -355,16 +357,33 @@ export function SettingsPage() {
   });
   });
 
 
   const deleteUserMutation = useMutation({
   const deleteUserMutation = useMutation({
-    mutationFn: (id: number) => api.deleteUser(id),
+    mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
       queryClient.invalidateQueries({ queryKey: ['users'] });
       showToast('User deleted successfully');
       showToast('User deleted successfully');
+      setDeleteUserId(null);
+      setDeleteUserItemCounts(null);
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
       showToast(error.message, 'error');
       showToast(error.message, 'error');
     },
     },
   });
   });
 
 
+  // Function to initiate user deletion with item count check
+  const handleDeleteUserClick = async (userId: number) => {
+    setDeleteUserId(userId);
+    setDeleteUserLoading(true);
+    try {
+      const counts = await api.getUserItemsCount(userId);
+      setDeleteUserItemCounts(counts);
+    } catch {
+      // If we can't get counts, just proceed without showing item options
+      setDeleteUserItemCounts({ archives: 0, queue_items: 0, library_files: 0 });
+    } finally {
+      setDeleteUserLoading(false);
+    }
+  };
+
   const createGroupMutation = useMutation({
   const createGroupMutation = useMutation({
     mutationFn: (data: GroupCreate) => api.createGroup(data),
     mutationFn: (data: GroupCreate) => api.createGroup(data),
     onSuccess: () => {
     onSuccess: () => {
@@ -3488,7 +3507,7 @@ export function SettingsPage() {
                                 </Button>
                                 </Button>
                               )}
                               )}
                               {hasPermission('users:delete') && userItem.id !== user?.id && (
                               {hasPermission('users:delete') && userItem.id !== user?.id && (
-                                <Button size="sm" variant="ghost" onClick={() => setDeleteUserId(userItem.id)}>
+                                <Button size="sm" variant="ghost" onClick={() => handleDeleteUserClick(userItem.id)}>
                                   <Trash2 className="w-4 h-4" />
                                   <Trash2 className="w-4 h-4" />
                                 </Button>
                                 </Button>
                               )}
                               )}
@@ -3892,17 +3911,101 @@ export function SettingsPage() {
 
 
       {/* Delete User Confirmation Modal */}
       {/* Delete User Confirmation Modal */}
       {deleteUserId !== null && (
       {deleteUserId !== null && (
-        <ConfirmModal
-          title="Delete User"
-          message="Are you sure you want to delete this user? This action cannot be undone."
-          confirmText="Delete User"
-          variant="danger"
-          onConfirm={() => {
-            deleteUserMutation.mutate(deleteUserId);
+        <div
+          className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
+          onClick={() => {
             setDeleteUserId(null);
             setDeleteUserId(null);
+            setDeleteUserItemCounts(null);
           }}
           }}
-          onCancel={() => setDeleteUserId(null)}
-        />
+        >
+          <Card
+            className="w-full max-w-md"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center gap-2 text-red-400">
+                <Trash2 className="w-5 h-5" />
+                <h3 className="text-lg font-semibold">Delete User</h3>
+              </div>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              {deleteUserLoading ? (
+                <div className="flex items-center justify-center py-4">
+                  <div className="animate-spin rounded-full h-6 w-6 border-2 border-bambu-green border-t-transparent" />
+                </div>
+              ) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? (
+                <>
+                  <p className="text-white">This user has created:</p>
+                  <ul className="list-disc list-inside text-bambu-gray space-y-1">
+                    {deleteUserItemCounts.archives > 0 && (
+                      <li>{deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}</li>
+                    )}
+                    {deleteUserItemCounts.queue_items > 0 && (
+                      <li>{deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}</li>
+                    )}
+                    {deleteUserItemCounts.library_files > 0 && (
+                      <li>{deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}</li>
+                    )}
+                  </ul>
+                  <p className="text-bambu-gray text-sm">What would you like to do with these items?</p>
+                  <div className="flex flex-col gap-2">
+                    <Button
+                      variant="danger"
+                      onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: true })}
+                      disabled={deleteUserMutation.isPending}
+                      className="justify-center"
+                    >
+                      Delete user AND their items
+                    </Button>
+                    <Button
+                      variant="secondary"
+                      onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}
+                      disabled={deleteUserMutation.isPending}
+                      className="justify-center"
+                    >
+                      Delete user, keep items (become ownerless)
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      onClick={() => {
+                        setDeleteUserId(null);
+                        setDeleteUserItemCounts(null);
+                      }}
+                      disabled={deleteUserMutation.isPending}
+                      className="justify-center"
+                    >
+                      Cancel
+                    </Button>
+                  </div>
+                </>
+              ) : (
+                <>
+                  <p className="text-white">Are you sure you want to delete this user?</p>
+                  <p className="text-bambu-gray text-sm">This action cannot be undone.</p>
+                  <div className="flex gap-2 justify-end">
+                    <Button
+                      variant="ghost"
+                      onClick={() => {
+                        setDeleteUserId(null);
+                        setDeleteUserItemCounts(null);
+                      }}
+                      disabled={deleteUserMutation.isPending}
+                    >
+                      Cancel
+                    </Button>
+                    <Button
+                      variant="danger"
+                      onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}
+                      disabled={deleteUserMutation.isPending}
+                    >
+                      Delete User
+                    </Button>
+                  </div>
+                </>
+              )}
+            </CardContent>
+          </Card>
+        </div>
       )}
       )}
 
 
       {/* Create/Edit Group Modal */}
       {/* Create/Edit Group Modal */}

+ 2 - 2
frontend/src/pages/StatsPage.tsx

@@ -695,8 +695,8 @@ export function StatsPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={handleRecalculateCosts}
             onClick={handleRecalculateCosts}
-            disabled={isRecalculating || !hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
+            disabled={isRecalculating || !hasPermission('archives:update_all')}
+            title={!hasPermission('archives:update_all') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
           >
           >
             {isRecalculating ? (
             {isRecalculating ? (
               <Loader2 className="w-4 h-4 animate-spin" />
               <Loader2 className="w-4 h-4 animate-spin" />

+ 23 - 9
frontend/src/pages/StreamOverlayPage.tsx

@@ -9,6 +9,8 @@ type OverlaySize = 'small' | 'medium' | 'large';
 
 
 interface OverlayConfig {
 interface OverlayConfig {
   size: OverlaySize;
   size: OverlaySize;
+  fps: number;
+  showCamera: boolean;
   showProgress: boolean;
   showProgress: boolean;
   showLayers: boolean;
   showLayers: boolean;
   showEta: boolean;
   showEta: boolean;
@@ -20,8 +22,18 @@ interface OverlayConfig {
 function parseConfig(params: URLSearchParams): OverlayConfig {
 function parseConfig(params: URLSearchParams): OverlayConfig {
   const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
   const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
 
 
+  // Parse FPS (default 15, max 30, min 1)
+  const fpsParam = parseInt(params.get('fps') || '15', 10);
+  const fps = Math.min(Math.max(isNaN(fpsParam) ? 15 : fpsParam, 1), 30);
+
+  // Parse camera toggle (default true, set camera=false to hide)
+  const cameraParam = params.get('camera');
+  const showCamera = cameraParam !== 'false' && cameraParam !== '0';
+
   return {
   return {
     size: (params.get('size') as OverlaySize) || 'medium',
     size: (params.get('size') as OverlaySize) || 'medium',
+    fps,
+    showCamera,
     showProgress: show.includes('progress'),
     showProgress: show.includes('progress'),
     showLayers: show.includes('layers'),
     showLayers: show.includes('layers'),
     showEta: show.includes('eta'),
     showEta: show.includes('eta'),
@@ -191,18 +203,20 @@ export function StreamOverlayPage() {
 
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const progress = status.progress || 0;
   const progress = status.progress || 0;
-  const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
+  const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
 
 
   return (
   return (
     <div className="min-h-screen bg-black relative overflow-hidden">
     <div className="min-h-screen bg-black relative overflow-hidden">
-      {/* Camera feed - fullscreen background */}
-      <img
-        key={imageKey}
-        src={streamUrl}
-        alt="Camera stream"
-        className="absolute inset-0 w-full h-full object-contain"
-        onError={handleStreamError}
-      />
+      {/* Camera feed - fullscreen background (optional) */}
+      {config.showCamera && (
+        <img
+          key={imageKey}
+          src={streamUrl}
+          alt="Camera stream"
+          className="absolute inset-0 w-full h-full object-contain"
+          onError={handleStreamError}
+        />
+      )}
 
 
       {/* Bambuddy logo - top right */}
       {/* Bambuddy logo - top right */}
       <a
       <a

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-1q7Yxq-H.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CPqcJWwC.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-d5ZW47G8.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DAZbTvYK.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-d5ZW47G8.css">
+    <script type="module" crossorigin src="/assets/index-1q7Yxq-H.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 1 - 1
test_backend.sh

@@ -1,5 +1,5 @@
 #!/bin/sh
 #!/bin/sh
 
 
 cd backend
 cd backend
-../venv/bin/python3 -m pytest tests/ -v
+../venv/bin/python3 -m pytest tests/ -v -n 14
 cd ..
 cd ..

+ 5 - 0
update_website_wiki.sh

@@ -10,6 +10,11 @@ git add .
 git commit -m "Updated Wiki"
 git commit -m "Updated Wiki"
 git push
 git push
 
 
+cd ../bambuddy-languages
+git add .
+git commit -m "Updated Bambuddy Languages"
+git push
+
 cd ../spoolbuddy-website
 cd ../spoolbuddy-website
 git add .
 git add .
 git commit -m "Updated website"
 git commit -m "Updated website"

Некоторые файлы не были показаны из-за большого количества измененных файлов