Browse Source

Merge pull request #219 from maziggy/0.1.7b

Merge fixes
MartinNYHC 3 months ago
parent
commit
f87ae85e24
51 changed files with 2828 additions and 2456 deletions
  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.
 
+## [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
 
 ### New Features

+ 2 - 1
README.md

@@ -59,7 +59,7 @@
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - 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
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
@@ -159,6 +159,7 @@
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - User management (create, edit, delete, groups)
+- User activity tracking (who uploaded archives, library files, queued prints, started prints)
 
 </td>
 </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.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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 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.services.archive import ArchiveService
 
@@ -96,6 +99,9 @@ def archive_to_response(
         "energy_kwh": archive.energy_kwh,
         "energy_cost": archive.energy_cost,
         "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
@@ -707,25 +713,42 @@ async def update_archive(
     archive_id: int,
     update_data: ArchiveUpdate,
     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)."""
     from sqlalchemy.orm import selectinload
 
+    user, can_modify_all = auth_result
+
     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()
     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 update your own archives")
+
     for field, value in update_data.model_dump(exclude_unset=True).items():
         setattr(archive, field, value)
 
     await db.commit()
 
-    # Re-fetch with project relationship loaded after commit
+    # Re-fetch with relationships loaded after commit
     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()
 
@@ -928,8 +951,30 @@ async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
 
 
 @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."""
+    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)
     if not await service.delete_archive(archive_id):
         raise HTTPException(404, "Archive not found")
@@ -2018,6 +2063,7 @@ async def upload_archive(
     file: UploadFile = File(...),
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """Manually upload a 3MF file to archive."""
     if not file.filename or not file.filename.endswith(".3mf"):
@@ -2035,6 +2081,7 @@ async def upload_archive(
         archive = await service.archive_print(
             printer_id=printer_id,
             source_file=temp_path,
+            created_by_id=current_user.id if current_user else None,
         )
 
         if not archive:
@@ -2051,6 +2098,7 @@ async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """Bulk upload multiple 3MF files to archive."""
     results = []
@@ -2072,6 +2120,7 @@ async def upload_archives_bulk(
             archive = await service.archive_print(
                 printer_id=printer_id,
                 source_file=temp_path,
+                created_by_id=current_user.id if current_user else None,
             )
 
             if archive:
@@ -2424,6 +2473,12 @@ async def reprint_archive(
     printer_id: int,
     body: ReprintRequest | None = None,
     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."""
     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
 
+    user, can_modify_all = auth_result
+
     # Use defaults if no body provided
     if body is None:
         body = ReprintRequest()
@@ -2445,6 +2502,11 @@ async def reprint_archive(
     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 reprint your own archives")
+
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -2476,14 +2538,22 @@ async def reprint_archive(
     # 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)
-    await delete_file_async(
+    logger.debug(f"Deleting existing file {remote_path} if present...")
+    delete_result = await delete_file_async(
         printer.ip_address,
         printer.access_code,
         remote_path,
         socket_timeout=ftp_timeout,
         printer_model=printer.model,
     )
+    logger.debug(f"Delete result: {delete_result}")
 
     if ftp_retry_enabled:
         uploaded = await with_ftp_retry(
@@ -2509,7 +2579,16 @@ async def reprint_archive(
         )
 
     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_expected_print(printer_id, remote_filename, archive_id)
@@ -2555,6 +2634,11 @@ async def reprint_archive(
     if not started:
         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 {
         "status": "printing",
         "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 sqlalchemy import func, select
 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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
+from backend.app.models.user import User
 from backend.app.schemas.library import (
     AddToQueueError,
     AddToQueueRequest,
@@ -75,6 +83,30 @@ def get_library_thumbnails_dir() -> Path:
     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:
     """Calculate SHA256 hash of a file."""
     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}")
-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))
     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.
                      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:
         query = query.where(LibraryFile.folder_id == folder_id)
@@ -610,6 +650,8 @@ async def list_files(
                 thumbnail_path=f.thumbnail_path,
                 print_count=f.print_count,
                 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,
                 print_name=print_name,
                 print_time_seconds=print_time,
@@ -627,6 +669,7 @@ async def upload_file(
     folder_id: int | None = None,
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """Upload a file to the library."""
     try:
@@ -722,16 +765,17 @@ async def upload_file(
             if generate_stl_thumbnails:
                 thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
-        # Create database entry
+        # Create database entry (store relative paths for portability)
         library_file = LibraryFile(
             folder_id=folder_id,
             filename=filename,
-            file_path=str(file_path),
+            file_path=to_relative_path(file_path),
             file_type=file_type,
             file_size=len(content),
             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,
+            created_by_id=current_user.id if current_user else None,
         )
         db.add(library_file)
         await db.flush()
@@ -761,6 +805,7 @@ async def extract_zip_file(
     create_folder_from_zip: bool = Query(default=False),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """Upload and extract a ZIP file to the library.
 
@@ -958,16 +1003,17 @@ async def extract_zip_file(
                         if generate_stl_thumbnails:
                             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
-                    # Create database entry
+                    # Create database entry (store relative paths for portability)
                     library_file = LibraryFile(
                         folder_id=target_folder_id,
                         filename=filename,
-                        file_path=str(file_path),
+                        file_path=to_relative_path(file_path),
                         file_type=file_type,
                         file_size=len(file_content),
                         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,
+                        created_by_id=current_user.id if current_user else None,
                     )
                     db.add(library_file)
                     await db.flush()
@@ -1062,9 +1108,9 @@ async def batch_generate_stl_thumbnails(
     failed = 0
 
     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(
                 BatchThumbnailResult(
                     file_id=stl_file.id,
@@ -1080,8 +1126,8 @@ async def batch_generate_stl_thumbnails(
             thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
 
             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()
                 results.append(
                     BatchThumbnailResult(
@@ -1633,14 +1679,22 @@ async def print_library_file(
     # 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)
-    await delete_file_async(
+    logger.debug(f"Deleting existing file {remote_path} if present...")
+    delete_result = await delete_file_async(
         printer.ip_address,
         printer.access_code,
         remote_path,
         socket_timeout=ftp_timeout,
         printer_model=printer.model,
     )
+    logger.debug(f"Delete result: {delete_result}")
 
     # Upload file to printer
     if ftp_retry_enabled:
@@ -1667,7 +1721,16 @@ async def print_library_file(
         )
 
     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_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)
 async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
     """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()
 
     if not file:
@@ -1782,20 +1847,39 @@ async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
         notes=file.notes,
         duplicates=duplicates if duplicates else None,
         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,
         updated_at=file.updated_at,
     )
 
 
 @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."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
         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:
         # Validate filename doesn't contain path separators
         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}")
-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."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
 
     if not file:
         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
     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:
         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:
         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")
 
     return FastAPIFileResponse(
-        file.file_path,
+        str(abs_path),
         filename=file.filename,
         media_type="application/octet-stream",
     )
@@ -1886,11 +1989,12 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     if not file:
         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")
 
     # Detect media type from extension
-    thumb_ext = os.path.splitext(file.thumbnail_path)[1].lower()
+    thumb_ext = abs_thumb_path.suffix.lower()
     media_types = {
         ".png": "image/png",
         ".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")
 
-    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")
@@ -1912,17 +2016,18 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
     if not file:
         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")
 
     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":
         # Extract gcode from 3mf
         import zipfile
 
         try:
-            with zipfile.ZipFile(file.file_path, "r") as zf:
+            with zipfile.ZipFile(str(abs_path), "r") as zf:
                 # Find gcode file
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 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)
-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_folders = 0
+    skipped_files = 0
 
     # Delete files first
     for file_id in data.file_ids:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
         if file:
+            # Ownership check
+            if not can_modify_all and file.created_by_id != user.id:
+                skipped_files += 1
+                continue
+
             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:
                 logger.warning(f"Failed to delete file from disk: {e}")
             await db.delete(file)
             deleted_files += 1
 
     # Delete folders (cascade will handle contents)
+    # Note: Folders don't have ownership tracking currently, require *_all permission
     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))
         folder = result.scalar_one_or_none()
         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.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.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
@@ -140,6 +143,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "completed_at": item.completed_at,
         "error_message": item.error_message,
         "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)
     if item.archive:
@@ -174,6 +180,7 @@ async def list_queue(
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.created_by),
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
@@ -196,6 +203,7 @@ async def list_queue(
 async def add_to_queue(
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
@@ -298,13 +306,14 @@ async def add_to_queue(
         use_ams=data.use_ams,
         position=max_pos + 1,
         status="pending",
+        created_by_id=current_user.id if current_user else None,
     )
     db.add(item)
     await db.commit()
     await db.refresh(item)
 
     # 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}"
     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(
     data: PrintQueueBulkUpdate,
     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.
 
     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:
         raise HTTPException(400, "No item IDs provided")
 
@@ -384,6 +402,11 @@ async def bulk_update_queue_items(
             skipped_count += 1
             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():
             setattr(item, field, value)
         updated_count += 1
@@ -394,7 +417,8 @@ async def bulk_update_queue_items(
     return PrintQueueBulkUpdateResponse(
         updated_count=updated_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.printer),
             selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.created_by),
         )
         .where(PrintQueueItem.id == item_id)
     )
@@ -421,13 +446,26 @@ async def update_queue_item(
     item_id: int,
     data: PrintQueueItemUpdate,
     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."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     if not item:
         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":
         raise HTTPException(400, "Can only update pending items")
 
@@ -469,20 +507,36 @@ async def update_queue_item(
         setattr(item, field, value)
 
     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}")
     return _enrich_response(item)
 
 
 @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."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     if not item:
         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":
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
@@ -511,13 +565,29 @@ async def reorder_queue(
 
 
 @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."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     if not item:
         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",):
         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
     item.manual_start = False
     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)")
     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)
 
-    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
         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")
 async def refresh_printer_status(
     printer_id: int,
@@ -1668,7 +1692,7 @@ async def refresh_ams_slot(
     printer_id: int,
     ams_id: int,
     slot_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
     db: AsyncSession = Depends(get_db),
 ):
     """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 json
 import zipfile
 from datetime import datetime
 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 sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings as app_settings
 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.smart_plug import SmartPlug
-from backend.app.models.user import User
 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"])
 
@@ -238,2057 +219,133 @@ async def update_spoolman_settings(
 
 
 @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
+    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()
         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)
         filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
+
         return StreamingResponse(
             zip_buffer,
             media_type="application/zip",
             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")
-async def import_backup(
+async def restore_backup(
     file: UploadFile = File(...),
-    overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"),
     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")

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

@@ -410,6 +410,10 @@ def _sanitize_log_content(content: str) -> str:
     # Replace email addresses
     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
     content = re.sub(r"/home/[^/\s]+/", "/home/[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.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.permissions import Permission
+from backend.app.models.archive import PrintArchive
 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.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 
@@ -198,13 +201,55 @@ async def update_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)
 async def delete_user(
     user_id: int,
+    delete_items: bool = Query(False, description="Delete all items created by this user"),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     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)))
     user = result.scalar_one_or_none()
     if not user:
@@ -241,6 +286,22 @@ async def delete_user(
             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.commit()
 

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

@@ -470,3 +470,79 @@ def RequirePermission(*permissions: str | Permission):
 def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     """Convenience dependency that requires permissions if auth is enabled."""
     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
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6"
+APP_VERSION = "0.1.7b"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # 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):
     pass
 
@@ -1000,6 +1020,60 @@ async def run_migrations(conn):
     except Exception:
         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():
     """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:
     - Users with role='admin' -> Administrators group
     - Users with role='user' -> Operators group
+
+    Also migrates old permissions to new ownership-based permissions (Issue #205).
     """
     import logging
 
@@ -1057,10 +1133,32 @@ async def seed_default_groups():
 
     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:
         # 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
         groups_created = []
@@ -1075,12 +1173,50 @@ async def seed_default_groups():
                 session.add(group)
                 groups_created.append(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()
 
         # Migrate existing users to groups if they're not already in any group
         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_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_CONTROL = "printers:control"  # Start/stop/pause/resume prints
     PRINTERS_FILES = "printers:files"  # Send files to printer
+    PRINTERS_AMS_RFID = "printers:ams_rfid"  # Re-read AMS RFID tags
 
     # Archives
     ARCHIVES_READ = "archives:read"
     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_READ = "queue:read"
     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"
 
     # Library
     LIBRARY_READ = "library:read"
     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_READ = "projects:read"
@@ -152,26 +160,34 @@ PERMISSION_CATEGORIES = {
         Permission.PRINTERS_DELETE,
         Permission.PRINTERS_CONTROL,
         Permission.PRINTERS_FILES,
+        Permission.PRINTERS_AMS_RFID,
     ],
     "Archives": [
         Permission.ARCHIVES_READ,
         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": [
         Permission.QUEUE_READ,
         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,
     ],
     "Library": [
         Permission.LIBRARY_READ,
         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": [
         Permission.PROJECTS_READ,
@@ -291,23 +307,24 @@ DEFAULT_GROUPS = {
             Permission.PRINTERS_DELETE.value,
             Permission.PRINTERS_CONTROL.value,
             Permission.PRINTERS_FILES.value,
-            # Archives - full access
+            Permission.PRINTERS_AMS_RFID.value,
+            # Archives - own items only
             Permission.ARCHIVES_READ.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_CREATE.value,
-            Permission.QUEUE_UPDATE.value,
-            Permission.QUEUE_DELETE.value,
+            Permission.QUEUE_UPDATE_OWN.value,
+            Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
-            # Library - full access
+            # Library - own items only
             Permission.LIBRARY_READ.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
             Permission.PROJECTS_READ.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:
         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
     try:
         printer_info = printer_manager.get_printer(printer_id)

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

@@ -69,10 +69,15 @@ class PrintArchive(Base):
     # Timestamps
     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
     printer: Mapped["Printer | 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.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
     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
     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())
@@ -89,7 +92,9 @@ class LibraryFile(Base):
     # Relationships
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     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.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
     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
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     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.library import LibraryFile  # noqa: E402
 from backend.app.models.printer import Printer  # 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
     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")
     maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
         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
     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
     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())
 
     # 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

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

@@ -89,6 +89,10 @@ class ArchiveResponse(BaseModel):
 
     created_at: datetime
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     @model_validator(mode="after")
     def compute_object_count(self) -> "ArchiveResponse":
         """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
     duplicate_count: int = 0
 
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
+
     created_at: datetime
     updated_at: datetime
 
@@ -141,6 +145,9 @@ class FileListResponse(BaseModel):
     thumbnail_path: str | None
     print_count: int
     duplicate_count: int = 0
+    # User tracking (Issue #206)
+    created_by_id: int | None = None
+    created_by_username: str | None = None
     created_at: datetime
 
     # 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
     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:
         from_attributes = True
 

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

@@ -806,8 +806,16 @@ class ArchiveService:
         printer_id: int | None,
         source_file: Path,
         print_data: dict | None = None,
+        created_by_id: int | None = 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
         if printer_id is not None:
             result = await self.db.execute(select(Printer).where(Printer.id == printer_id))
@@ -915,6 +923,7 @@ class ArchiveService:
             cost=cost,
             quantity=quantity,
             extra_data=metadata,
+            created_by_id=created_by_id,
         )
 
         self.db.add(archive)
@@ -924,8 +933,12 @@ class ArchiveService:
         return archive
 
     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()
 
     async def update_archive_status(
@@ -997,7 +1010,9 @@ class ArchiveService:
         from sqlalchemy.orm import selectinload
 
         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:

+ 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"(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)
             logger.debug("FTP connected, logging in as bblp")
             self._ftp.login("bblp", self.access_code)
             logger.debug("FTP logged in, setting prot_p and passive mode")
             self._ftp.prot_p()
             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
+        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:
-            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
             return False
 
@@ -227,6 +242,62 @@ class BambuFTPClient:
                     pass
             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(
         self,
         local_path: Path,
@@ -242,6 +313,17 @@ class BambuFTPClient:
             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}")
 
+            # 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
 
             def on_block(block: bytes):
@@ -254,11 +336,27 @@ class BambuFTPClient:
                 if self._should_skip_session_reuse():
                     ftplib._SSLSocket = None
 
+                logger.debug(f"FTP STOR command starting for {remote_path}")
                 self._ftp.storbinary(f"STOR {remote_path}", f, callback=on_block)
             logger.info(f"FTP upload complete: {remote_path}")
             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:
-            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
 
     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:
                     # Ensure tray_id is an integer (may be string from JSON)
                     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})
+                    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:
-                        # Global tray ID = (ams_id * 4) + slot_id
+                        # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         ams_id = tray_id // 4
                         slot_id = tray_id % 4
                         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
         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)
         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.access_code,
                 remote_path,
                 socket_timeout=ftp_timeout,
                 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:
             if ftp_retry_enabled:
@@ -894,14 +902,21 @@ class PrintScheduler:
                 )
         except Exception as e:
             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:
+            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.error_message = "Failed to upload file to printer"
+            item.error_message = error_msg
             item.completed_at = datetime.utcnow()
             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
             await notification_service.on_queue_job_failed(
@@ -911,7 +926,6 @@ class PrintScheduler:
                 reason="Failed to upload file to printer",
                 db=db,
             )
-
             await self._power_off_if_needed(db, item)
             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_layer_change: Callable[[int, int], None] | 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:
         """Get printer info by 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):
         """Set the event loop for async callbacks."""
         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_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:
     """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"]}
         assert stl_without_thumb1.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"
             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:
     """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
         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.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 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
             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:
     """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', () => {
     beforeEach(() => {
       server.use(

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

@@ -324,6 +324,9 @@ export interface Archive {
   energy_kwh: number | null;
   energy_cost: number | null;
   created_at: string;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
 }
 
 export interface ArchiveStats {
@@ -1094,6 +1097,9 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   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 {
@@ -1721,10 +1727,15 @@ export interface ExternalLinkUpdate {
 
 // Permission type - all available permissions
 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'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
@@ -1886,10 +1897,12 @@ export const api = {
       method: 'PATCH',
       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',
     }),
+  getUserItemsCount: (id: number) =>
+    request<{ archives: number; queue_items: number; library_files: number }>(`/users/${id}/items-count`),
   changePassword: (currentPassword: string, newPassword: string) =>
     request<{ message: string }>('/users/me/change-password', {
       method: 'POST',
@@ -1975,6 +1988,10 @@ export const api = {
       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
   setChamberLight: (printerId: number, on: boolean) =>
     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 }> => {
     const formData = new FormData();
     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`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -2273,8 +2295,13 @@ export const api = {
     if (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`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -2289,7 +2316,12 @@ export const api = {
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
     const formData = new FormData();
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
+      headers,
       method: 'POST',
       body: formData,
     });
@@ -2311,8 +2343,13 @@ export const api = {
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -2331,8 +2368,13 @@ export const api = {
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -2461,8 +2503,13 @@ export const api = {
     const url = printerId
       ? `${API_BASE}/archives/upload?printer_id=${printerId}`
       : `${API_BASE}/archives/upload`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -2477,8 +2524,13 @@ export const api = {
     const url = printerId
       ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
       : `${API_BASE}/archives/upload-bulk`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -2498,25 +2550,9 @@ export const api = {
   getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
   resetSettings: () =>
     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);
 
     // Check for errors
@@ -2527,7 +2563,7 @@ export const api = {
 
     // Get filename from Content-Disposition header
     const contentDisposition = response.headers.get('Content-Disposition');
-    let filename = 'bambuddy-backup.json';
+    let filename = 'bambuddy-backup.zip';
     if (contentDisposition) {
       const match = contentDisposition.match(/filename=([^;]+)/);
       if (match) filename = match[1].trim();
@@ -2536,22 +2572,23 @@ export const api = {
     const blob = await response.blob();
     return { blob, filename };
   },
-  importBackup: async (file: File, overwrite = false) => {
+  importBackup: async (file: File) => {
+    // New simplified restore - replaces database + all directories
     const formData = new FormData();
     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, {
       method: 'POST',
+      headers,
       body: formData,
     });
     return response.json() as Promise<{
       success: boolean;
       message: string;
-      restored?: Record<string, number>;
-      skipped?: Record<string, number>;
-      skipped_details?: Record<string, string[]>;
-      files_restored?: number;
-      total_skipped?: number;
     }>;
   },
   checkFfmpeg: () =>
@@ -3047,8 +3084,13 @@ export const api = {
   uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
     const formData = new FormData();
     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`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -3107,8 +3149,13 @@ export const api = {
   }> => {
     const formData = new FormData();
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -3225,8 +3272,13 @@ export const api = {
     const params = new URLSearchParams();
     if (folderId) params.set('folder_id', String(folderId));
     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}`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -3249,8 +3301,13 @@ export const api = {
     params.set('preserve_structure', String(preserveStructure));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     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}`, {
       method: 'POST',
+      headers,
       body: formData,
     });
     if (!response.ok) {
@@ -3550,6 +3607,9 @@ export interface LibraryFile {
   notes: string | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicate_count: number;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
   created_at: string;
   updated_at: string;
 }
@@ -3563,6 +3623,9 @@ export interface LibraryFileListItem {
   thumbnail_path: string | null;
   print_count: number;
   duplicate_count: number;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
   created_at: string;
   print_name: string | 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]);
 
-  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 (
     <div

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

@@ -16,6 +16,7 @@ import {
   SkipForward,
   AlertTriangle,
   Trash2,
+  RotateCcw,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -31,8 +32,7 @@ import type {
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
-import { BackupModal } from './BackupModal';
-import { RestoreModal } from './RestoreModal';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 
 interface StatusBadgeProps {
@@ -108,9 +108,30 @@ export function GitHubBackupSettings() {
   const [backupSettings, setBackupSettings] = useState(false);
   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
   const [testLoading, setTestLoading] = useState(false);
@@ -696,80 +717,185 @@ export function GitHubBackupSettings() {
           </CardHeader>
           <CardContent className="space-y-4">
             <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>
 
+            {/* Export */}
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
               <div>
-                <p className="text-white">Export Data</p>
+                <p className="text-white">Download Backup</p>
                 <p className="text-sm text-bambu-gray">
-                  Download all settings, printers, and profiles
+                  Complete backup: database + all files (ZIP)
                 </p>
               </div>
               <Button
                 variant="secondary"
                 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" />
-                Export
+                Download
               </Button>
             </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>
-                <p className="text-white">Import Backup</p>
+                <p className="text-white">Restore Backup</p>
                 <p className="text-sm text-bambu-gray">
-                  Restore from a previous export file
+                  Replace all data from a backup file
                 </p>
               </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
                 variant="secondary"
                 size="sm"
-                onClick={() => setShowRestoreModal(true)}
+                disabled={isRestoring || isExporting}
+                onClick={() => fileInputRef.current?.click()}
               >
                 <Upload className="w-4 h-4" />
-                Import
+                Restore
               </Button>
             </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>
         </Card>
       </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 {
-              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>
   );

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

@@ -15,6 +15,7 @@ interface AuthContextType {
   hasPermission: (permission: Permission) => boolean;
   hasAnyPermission: (...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);
@@ -156,6 +157,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     return permissions.every(p => permissionSet.has(p));
   }, [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 (
     <AuthContext.Provider
       value={{
@@ -171,6 +197,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         hasPermission,
         hasAnyPermission,
         hasAllPermissions,
+        canModify,
       }}
     >
       {children}

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

@@ -44,6 +44,7 @@ import {
   ChevronLeft,
   ChevronRight,
   Settings,
+  User,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
@@ -123,7 +124,7 @@ function ArchiveCard({
 
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -286,8 +287,8 @@ function ArchiveCard({
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         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',
@@ -341,8 +342,8 @@ function ArchiveCard({
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       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: () => {} },
     {
@@ -358,30 +359,30 @@ function ArchiveCard({
           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 ? [{
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       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',
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(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',
       icon: <Box className="w-4 h-4" />,
       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 ? [{
       label: 'Download F3D',
@@ -398,8 +399,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(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: () => {} },
     {
@@ -449,15 +450,15 @@ function ArchiveCard({
       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' : ''}`} />,
       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',
       icon: <Pencil className="w-4 h-4" />,
       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 ? [{
       label: `Go to Project: ${archive.project_name}`,
@@ -468,8 +469,8 @@ function ArchiveCard({
       label: 'Add to Project',
       icon: <FolderKanban className="w-4 h-4" />,
       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: (() => {
         const items: ContextMenuItem[] = [];
 
@@ -479,7 +480,7 @@ function ArchiveCard({
             label: 'Remove from Project',
             icon: <X className="w-4 h-4" />,
             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,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 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" />,
       onClick: () => setShowDeleteConfirm(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 */}
         <button
           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/30 cursor-not-allowed'
           }`}
           onClick={(e) => {
             e.stopPropagation();
-            if (hasPermission('archives:update')) {
+            if (canModify('archives', 'update', archive.created_by_id)) {
               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
-            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>
         {(archive.status === 'failed' || archive.status === 'aborted') && (
@@ -886,10 +887,18 @@ function ArchiveCard({
         {/* Spacer to push content to bottom */}
         <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">
           <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>
 
         {/* Actions */}
@@ -902,12 +911,23 @@ function ArchiveCard({
                 size="sm"
                 className="flex-1 min-w-0"
                 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" />
                 <span className="hidden sm:inline">Reprint</span>
               </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
                 variant="secondary"
                 size="sm"
@@ -986,8 +1006,8 @@ function ArchiveCard({
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             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" />
           </Button>
@@ -996,8 +1016,8 @@ function ArchiveCard({
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             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" />
           </Button>
@@ -1254,7 +1274,7 @@ function ArchiveListRow({
 }) {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -1399,8 +1419,8 @@ function ArchiveListRow({
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         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',
@@ -1454,8 +1474,8 @@ function ArchiveListRow({
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       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: () => {} },
     {
@@ -1471,30 +1491,30 @@ function ArchiveListRow({
           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 ? [{
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       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',
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(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',
       icon: <Box className="w-4 h-4" />,
       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 ? [{
       label: 'Download F3D',
@@ -1511,8 +1531,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(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: () => {} },
     {
@@ -1562,15 +1582,15 @@ function ArchiveListRow({
       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' : ''}`} />,
       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',
       icon: <Pencil className="w-4 h-4" />,
       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 ? [{
       label: `Go to Project: ${archive.project_name}`,
@@ -1631,8 +1651,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(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}
         </div>
         <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 className="col-span-1 text-sm text-bambu-gray">
           {formatFileSize(archive.file_size)}
@@ -1765,8 +1791,8 @@ function ArchiveListRow({
             variant="ghost"
             size="sm"
             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" />
           </Button>
@@ -1774,8 +1800,8 @@ function ArchiveListRow({
             variant="ghost"
             size="sm"
             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" />
           </Button>
@@ -2029,7 +2055,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 export function ArchivesPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission } = useAuth();
   const searchInputRef = useRef<HTMLInputElement>(null);
   const [search, setSearch] = useState('');
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
@@ -2445,8 +2471,8 @@ export function ArchivesPage() {
             variant="secondary"
             size="sm"
             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" />
             Tags
@@ -2455,8 +2481,8 @@ export function ArchivesPage() {
             variant="secondary"
             size="sm"
             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" />
             Project
@@ -2464,8 +2490,8 @@ export function ArchivesPage() {
           <Button
             variant="secondary"
             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={() => {
               const ids = Array.from(selectedIds);
               Promise.all(ids.map(id => api.toggleFavorite(id)))
@@ -2485,8 +2511,8 @@ export function ArchivesPage() {
             size="sm"
             className="bg-red-500 hover:bg-red-600"
             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" />
             Delete

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

@@ -531,7 +531,7 @@ export function CameraPage() {
   const currentUrl = transitioning
     ? ''
     : 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}`;
 
   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]">
                 <button
                   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" />
                   Rename
                 </button>
                 <button
                   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" />
                   {isLinked ? 'Change Link...' : 'Link to...'}
                 </button>
                 <button
                   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" />
                   Delete
@@ -882,9 +882,10 @@ interface FileCardProps {
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
   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);
 
   return (
@@ -937,6 +938,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             Printed {file.print_count}x
           </div>
         )}
+        {file.created_by_username && (
+          <div className="mt-1 text-xs text-bambu-gray">
+            Uploaded by {file.created_by_username}
+          </div>
+        )}
       </div>
 
       {/* Actions - always visible on mobile, hover on desktop */}
@@ -991,11 +997,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               {onRename && (
                 <button
                   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" />
                   Rename
@@ -1004,11 +1010,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               {onGenerateThumbnail && file.file_type === 'stl' && (
                 <button
                   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" />
                   Generate Thumbnail
@@ -1016,11 +1022,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               )}
               <button
                 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" />
                 Delete
@@ -1045,7 +1051,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
 export function FileManagerPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [searchParams] = useSearchParams();
 
   // Read folder ID from URL query parameter
@@ -1512,8 +1518,8 @@ export function FileManagerPage() {
           <Button
             variant="secondary"
             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 ? (
               <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -1827,8 +1833,8 @@ export function FileManagerPage() {
                       variant="secondary"
                       size="sm"
                       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" />
                       <span className="hidden sm:inline">Move</span>
@@ -1843,8 +1849,8 @@ export function FileManagerPage() {
                           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" />
                       <span className="hidden sm:inline">Delete</span>
@@ -1924,6 +1930,7 @@ export function FileManagerPage() {
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                 ))}
               </div>
@@ -2048,40 +2055,40 @@ export function FileManagerPage() {
                         <Download className="w-4 h-4" />
                       </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 ${
-                          hasPermission('library:update')
+                          canModify('library', 'update', file.created_by_id)
                             ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
                             : '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" />
                       </button>
                       {file.file_type === 'stl' && (
                         <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 ${
-                            hasPermission('library:update')
+                            canModify('library', 'update', file.created_by_id)
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : '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" />
                         </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 ${
-                          hasPermission('library:delete')
+                          canModify('library', 'delete', file.created_by_id)
                             ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
                             : '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" />
                       </button>

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

@@ -40,6 +40,7 @@ import {
   ScanSearch,
   CheckCircle,
   XCircle,
+  User,
 } from 'lucide-react';
 
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -1116,6 +1117,23 @@ function PrinterCard({
   });
   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
   const { data: lastPrints } = useQuery({
     queryKey: ['archives', printer.id, 'last'],
@@ -1896,6 +1914,12 @@ function PrinterCard({
                                 {status.layer_num}/{status.total_layers}
                               </span>
                             )}
+                            {currentPrintUser && (
+                              <span className="flex items-center gap-1" title={`Started by ${currentPrintUser}`}>
+                                <User className="w-3 h-3" />
+                                {currentPrintUser}
+                              </span>
+                            )}
                           </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]">
                                         <button
                                           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-bambu-gray/50 cursor-not-allowed'
                                           }`}
                                           onClick={(e) => {
                                             e.stopPropagation();
-                                            if (!hasPermission('printers:control')) return;
+                                            if (!hasPermission('printers:ams_rfid')) return;
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             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' : ''}`} />
                                           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]">
                                     <button
                                       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-bambu-gray/50 cursor-not-allowed'
                                       }`}
                                       onClick={(e) => {
                                         e.stopPropagation();
-                                        if (!hasPermission('printers:control')) return;
+                                        if (!hasPermission('printers:ams_rfid')) return;
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         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' : ''}`} />
                                       Re-read RFID

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

@@ -44,6 +44,7 @@ import {
   Check,
   CheckSquare,
   Square,
+  User,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -279,6 +280,7 @@ function SortableQueueItem({
   isSelected = false,
   onToggleSelect,
   hasPermission,
+  canModify,
 }: {
   item: PrintQueueItem;
   position?: number;
@@ -292,6 +294,7 @@ function SortableQueueItem({
   isSelected?: boolean;
   onToggleSelect?: () => void;
   hasPermission: (permission: Permission) => boolean;
+  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
 }) {
   const canReorder = hasPermission('queue:reorder');
   const {
@@ -427,6 +430,12 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
               </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 && (
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
@@ -518,8 +527,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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" />
               </Button>
@@ -527,8 +536,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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"
               >
                 <X className="w-4 h-4" />
@@ -551,8 +560,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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" />
               </Button>
@@ -567,7 +576,7 @@ function SortableQueueItem({
 export function QueuePage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
@@ -921,8 +930,8 @@ export function QueuePage() {
             variant="secondary"
             size="sm"
             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" />
             Clear History
@@ -963,6 +972,7 @@ export function QueuePage() {
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                 ))}
               </div>
@@ -1032,8 +1042,8 @@ export function QueuePage() {
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
                       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" />
                       Edit Selected
@@ -1043,8 +1053,8 @@ export function QueuePage() {
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       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" />
                       Cancel Selected
@@ -1078,6 +1088,7 @@ export function QueuePage() {
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         hasPermission={hasPermission}
+                        canModify={canModify}
                       />
                     ))}
                   </div>
@@ -1132,6 +1143,7 @@ export function QueuePage() {
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                 ))}
               </div>

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

@@ -95,6 +95,8 @@ export function SettingsPage() {
   const [showEditUserModal, setShowEditUserModal] = useState(false);
   const [editingUserId, setEditingUserId] = 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<{
     username: string;
     password: string;
@@ -355,16 +357,33 @@ export function SettingsPage() {
   });
 
   const deleteUserMutation = useMutation({
-    mutationFn: (id: number) => api.deleteUser(id),
+    mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
       showToast('User deleted successfully');
+      setDeleteUserId(null);
+      setDeleteUserItemCounts(null);
     },
     onError: (error: 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({
     mutationFn: (data: GroupCreate) => api.createGroup(data),
     onSuccess: () => {
@@ -3488,7 +3507,7 @@ export function SettingsPage() {
                                 </Button>
                               )}
                               {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" />
                                 </Button>
                               )}
@@ -3892,17 +3911,101 @@ export function SettingsPage() {
 
       {/* Delete User Confirmation Modal */}
       {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);
+            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 */}

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

@@ -695,8 +695,8 @@ export function StatsPage() {
           <Button
             variant="secondary"
             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 ? (
               <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 {
   size: OverlaySize;
+  fps: number;
+  showCamera: boolean;
   showProgress: boolean;
   showLayers: boolean;
   showEta: boolean;
@@ -20,8 +22,18 @@ interface OverlayConfig {
 function parseConfig(params: URLSearchParams): OverlayConfig {
   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 {
     size: (params.get('size') as OverlaySize) || 'medium',
+    fps,
+    showCamera,
     showProgress: show.includes('progress'),
     showLayers: show.includes('layers'),
     showEta: show.includes('eta'),
@@ -191,18 +203,20 @@ export function StreamOverlayPage() {
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   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 (
     <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 */}
       <a

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-1q7Yxq-H.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CPqcJWwC.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-d5ZW47G8.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <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>
   <body>
     <div id="root"></div>

+ 1 - 1
test_backend.sh

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

+ 5 - 0
update_website_wiki.sh

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

Some files were not shown because too many files changed in this diff