Procházet zdrojové kódy

Add user tracking for prints, archives, library files, and queue (Issue #206)

Track and display who performs key actions in Bambuddy:
- Archives: who uploaded each archive file
- Library: who uploaded each file in File Manager
- Queue: who added each print job to the queue
- Printers: who started the current print (reprint tracking)

Backend changes:
- Add created_by_id column to print_archives, library_files, print_queue tables
- Add database migrations for new columns (auto-run on startup)
- Update archive, library, and queue routes to capture current user
- Add current-print-user endpoint for printer reprint tracking
- Track reprint user in PrinterManager in-memory state
- Fix file uploads not sending auth headers (FormData requires explicit headers)

Frontend changes:
- Display username on archive cards, library files, queue items
- Show "Started by" on printer cards during active prints
- Add auth headers to all 12 FormData upload functions
- Update TypeScript types for user tracking fields

Tests:
- Add unit tests for PrinterManager user tracking methods (7 tests)
- Add integration tests for current-print-user endpoint (3 tests)
- Add integration tests for library file user tracking (3 tests)

Works when authentication is enabled; gracefully hidden when disabled.

Closes #206
maziggy před 3 měsíci
rodič
revize
81cc8412ac

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.7b] - Not released
 ## [0.1.7b] - Not released
 
 
 ### Enhancements
 ### Enhancements
+- **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
 - **Schedule Button on Archive Cards** (Issue #208):
 - **Schedule Button on Archive Cards** (Issue #208):
   - Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling
   - Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling
   - Previously only available in the context menu (right-click)
   - Previously only available in the context menu (right-click)
@@ -26,6 +33,7 @@ All notable changes to Bambuddy will be documented in this file.
   - Library now stores relative paths in database for portability
   - Library now stores relative paths in database for portability
   - Automatic migration converts existing absolute paths to relative on startup
   - Automatic migration converts existing absolute paths to relative on startup
   - Thumbnails and files now display correctly after restoring backups
   - Thumbnails and files now display correctly after restoring backups
+- **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
 
 
 ## [0.1.6-final] - 2026-01-31
 ## [0.1.6-final] - 2026-01-31
 
 

+ 1 - 0
README.md

@@ -159,6 +159,7 @@
 - Default groups: Administrators, Operators, Viewers
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - JWT tokens with secure password hashing
 - User management (create, edit, delete, groups)
 - User management (create, edit, delete, groups)
+- User activity tracking (who uploaded archives, library files, queued prints, started prints)
 
 
 </td>
 </td>
 </tr>
 </tr>

+ 15 - 0
backend/app/api/routes/archives.py

@@ -8,10 +8,12 @@ from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.auth import require_auth_if_enabled
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
+from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.archive import ArchiveService
 
 
@@ -96,6 +98,9 @@ def archive_to_response(
         "energy_kwh": archive.energy_kwh,
         "energy_kwh": archive.energy_kwh,
         "energy_cost": archive.energy_cost,
         "energy_cost": archive.energy_cost,
         "created_at": archive.created_at,
         "created_at": archive.created_at,
+        # User tracking (Issue #206)
+        "created_by_id": archive.created_by_id,
+        "created_by_username": archive.created_by.username if archive.created_by else None,
     }
     }
 
 
     # Add computed time accuracy fields
     # Add computed time accuracy fields
@@ -2018,6 +2023,7 @@ async def upload_archive(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Manually upload a 3MF file to archive."""
     """Manually upload a 3MF file to archive."""
     if not file.filename or not file.filename.endswith(".3mf"):
     if not file.filename or not file.filename.endswith(".3mf"):
@@ -2035,6 +2041,7 @@ async def upload_archive(
         archive = await service.archive_print(
         archive = await service.archive_print(
             printer_id=printer_id,
             printer_id=printer_id,
             source_file=temp_path,
             source_file=temp_path,
+            created_by_id=current_user.id if current_user else None,
         )
         )
 
 
         if not archive:
         if not archive:
@@ -2051,6 +2058,7 @@ async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Bulk upload multiple 3MF files to archive."""
     """Bulk upload multiple 3MF files to archive."""
     results = []
     results = []
@@ -2072,6 +2080,7 @@ async def upload_archives_bulk(
             archive = await service.archive_print(
             archive = await service.archive_print(
                 printer_id=printer_id,
                 printer_id=printer_id,
                 source_file=temp_path,
                 source_file=temp_path,
+                created_by_id=current_user.id if current_user else None,
             )
             )
 
 
             if archive:
             if archive:
@@ -2424,6 +2433,7 @@ async def reprint_archive(
     printer_id: int,
     printer_id: int,
     body: ReprintRequest | None = None,
     body: ReprintRequest | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Send an archived 3MF file to a printer and start printing."""
     """Send an archived 3MF file to a printer and start printing."""
     from backend.app.main import register_expected_print
     from backend.app.main import register_expected_print
@@ -2555,6 +2565,11 @@ async def reprint_archive(
     if not started:
     if not started:
         raise HTTPException(500, "Failed to start print")
         raise HTTPException(500, "Failed to start print")
 
 
+    # Track who started this print (Issue #206)
+    if current_user:
+        printer_manager.set_current_print_user(printer_id, current_user.id, current_user.username)
+        logger.info(f"Reprint started by user: {current_user.username}")
+
     return {
     return {
         "status": "printing",
         "status": "printing",
         "printer_id": printer_id,
         "printer_id": printer_id,

+ 15 - 2
backend/app/api/routes/library.py

@@ -13,13 +13,16 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, Up
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.auth import require_auth_if_enabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project import Project
+from backend.app.models.user import User
 from backend.app.schemas.library import (
 from backend.app.schemas.library import (
     AddToQueueError,
     AddToQueueError,
     AddToQueueRequest,
     AddToQueueRequest,
@@ -587,7 +590,7 @@ async def list_files(
         include_root: If True and folder_id is None, returns files at root level.
         include_root: If True and folder_id is None, returns files at root level.
                      If False and folder_id is None, returns all files.
                      If False and folder_id is None, returns all files.
     """
     """
-    query = select(LibraryFile)
+    query = select(LibraryFile).options(selectinload(LibraryFile.created_by))
 
 
     if folder_id is not None:
     if folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
         query = query.where(LibraryFile.folder_id == folder_id)
@@ -634,6 +637,8 @@ async def list_files(
                 thumbnail_path=f.thumbnail_path,
                 thumbnail_path=f.thumbnail_path,
                 print_count=f.print_count,
                 print_count=f.print_count,
                 duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0,
                 duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0,
+                created_by_id=f.created_by_id,
+                created_by_username=f.created_by.username if f.created_by else None,
                 created_at=f.created_at,
                 created_at=f.created_at,
                 print_name=print_name,
                 print_name=print_name,
                 print_time_seconds=print_time,
                 print_time_seconds=print_time,
@@ -651,6 +656,7 @@ async def upload_file(
     folder_id: int | None = None,
     folder_id: int | None = None,
     generate_stl_thumbnails: bool = Query(default=True),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Upload a file to the library."""
     """Upload a file to the library."""
     try:
     try:
@@ -756,6 +762,7 @@ async def upload_file(
             file_hash=file_hash,
             file_hash=file_hash,
             thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
             thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
             file_metadata=metadata if metadata else None,
             file_metadata=metadata if metadata else None,
+            created_by_id=current_user.id if current_user else None,
         )
         )
         db.add(library_file)
         db.add(library_file)
         await db.flush()
         await db.flush()
@@ -785,6 +792,7 @@ async def extract_zip_file(
     create_folder_from_zip: bool = Query(default=False),
     create_folder_from_zip: bool = Query(default=False),
     generate_stl_thumbnails: bool = Query(default=True),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Upload and extract a ZIP file to the library.
     """Upload and extract a ZIP file to the library.
 
 
@@ -992,6 +1000,7 @@ async def extract_zip_file(
                         file_hash=file_hash,
                         file_hash=file_hash,
                         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
                         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
                         file_metadata=metadata if metadata else None,
                         file_metadata=metadata if metadata else None,
+                        created_by_id=current_user.id if current_user else None,
                     )
                     )
                     db.add(library_file)
                     db.add(library_file)
                     await db.flush()
                     await db.flush()
@@ -1749,7 +1758,9 @@ async def print_library_file(
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
 async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
 async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
     """Get a file by ID with full details."""
     """Get a file by ID with full details."""
-    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    result = await db.execute(
+        select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
+    )
     file = result.scalar_one_or_none()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
@@ -1806,6 +1817,8 @@ async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
         notes=file.notes,
         notes=file.notes,
         duplicates=duplicates if duplicates else None,
         duplicates=duplicates if duplicates else None,
         duplicate_count=duplicate_count,
         duplicate_count=duplicate_count,
+        created_by_id=file.created_by_id,
+        created_by_username=file.created_by.username if file.created_by else None,
         created_at=file.created_at,
         created_at=file.created_at,
         updated_at=file.updated_at,
         updated_at=file.updated_at,
     )
     )

+ 12 - 3
backend/app/api/routes/print_queue.py

@@ -12,12 +12,14 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.auth import require_auth_if_enabled
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
 from backend.app.schemas.print_queue import (
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueBulkUpdateResponse,
@@ -140,6 +142,9 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "completed_at": item.completed_at,
         "completed_at": item.completed_at,
         "error_message": item.error_message,
         "error_message": item.error_message,
         "created_at": item.created_at,
         "created_at": item.created_at,
+        # User tracking (Issue #206)
+        "created_by_id": item.created_by_id,
+        "created_by_username": item.created_by.username if item.created_by else None,
     }
     }
     response = PrintQueueItemResponse(**item_dict)
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
     if item.archive:
@@ -174,6 +179,7 @@ async def list_queue(
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.created_by),
         )
         )
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
         .order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
     )
     )
@@ -196,6 +202,7 @@ async def list_queue(
 async def add_to_queue(
 async def add_to_queue(
     data: PrintQueueItemCreate,
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
 ):
 ):
     """Add an item to the print queue."""
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
@@ -298,13 +305,14 @@ async def add_to_queue(
         use_ams=data.use_ams,
         use_ams=data.use_ams,
         position=max_pos + 1,
         position=max_pos + 1,
         status="pending",
         status="pending",
+        created_by_id=current_user.id if current_user else None,
     )
     )
     db.add(item)
     db.add(item)
     await db.commit()
     await db.commit()
     await db.refresh(item)
     await db.refresh(item)
 
 
     # Load relationships for response
     # Load relationships for response
-    await db.refresh(item, ["archive", "printer", "library_file"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     source_name = f"archive {data.archive_id}" if data.archive_id else f"library file {data.library_file_id}"
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
     target_desc = data.printer_id or (f"model {target_model_norm}" if target_model_norm else "unassigned")
@@ -407,6 +415,7 @@ async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.printer),
             selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.library_file),
+            selectinload(PrintQueueItem.created_by),
         )
         )
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
@@ -469,7 +478,7 @@ async def update_queue_item(
         setattr(item, field, value)
         setattr(item, field, value)
 
 
     await db.commit()
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
     logger.info(f"Updated queue item {item_id}")
     logger.info(f"Updated queue item {item_id}")
     return _enrich_response(item)
     return _enrich_response(item)
@@ -624,7 +633,7 @@ async def start_queue_item(
     # Clear manual_start flag so scheduler picks it up
     # Clear manual_start flag so scheduler picks it up
     item.manual_start = False
     item.manual_start = False
     await db.commit()
     await db.commit()
-    await db.refresh(item, ["archive", "printer", "library_file"])
+    await db.refresh(item, ["archive", "printer", "library_file", "created_by"])
 
 
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
     return _enrich_response(item)
     return _enrich_response(item)

+ 21 - 0
backend/app/api/routes/printers.py

@@ -444,6 +444,27 @@ async def get_printer_status(
     )
     )
 
 
 
 
+@router.get("/{printer_id}/current-print-user")
+async def get_current_print_user(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the user who started the current print (for reprint tracking).
+
+    Returns user info if available, empty object otherwise.
+    This tracks users for reprints (which bypass the queue).
+    For queue-based prints, use the queue item's created_by field instead.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    user_info = printer_manager.get_current_print_user(printer_id)
+    return user_info or {}
+
+
 @router.post("/{printer_id}/refresh-status")
 @router.post("/{printer_id}/refresh-status")
 async def refresh_printer_status(
 async def refresh_printer_status(
     printer_id: int,
     printer_id: int,

+ 24 - 0
backend/app/core/database.py

@@ -1014,6 +1014,30 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
+    # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
+    try:
+        await conn.execute(
+            text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
+    try:
+        await conn.execute(
+            text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
+    # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
+    try:
+        await conn.execute(
+            text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
+        )
+    except Exception:
+        pass
+
     # Migration: Convert absolute paths to relative paths in library_files table
     # Migration: Convert absolute paths to relative paths in library_files table
     # This ensures backup/restore portability across different installations
     # This ensures backup/restore portability across different installations
     try:
     try:

+ 3 - 0
backend/app/main.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -254,6 +254,74 @@ class TestLibraryFilesAPI:
         assert result["total_folders"] == 2
         assert result["total_folders"] == 2
         assert result["total_files"] == 1
         assert result["total_files"] == 1
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file list response includes user tracking fields (Issue #206)."""
+        lib_file = await file_factory(filename="test.3mf")
+        response = await async_client.get("/api/v1/library/files?include_root=false")
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result) >= 1
+        # Find our test file
+        test_file = next((f for f in result if f["id"] == lib_file.id), None)
+        assert test_file is not None
+        # User tracking fields should be present (even if null)
+        assert "created_by_id" in test_file
+        assert "created_by_username" in test_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify file detail response includes user tracking fields (Issue #206)."""
+        lib_file = await file_factory(filename="test_detail.3mf")
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
+        assert response.status_code == 200
+        result = response.json()
+        # User tracking fields should be present (even if null)
+        assert "created_by_id" in result
+        assert "created_by_username" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
+        """Verify file created with user shows username in response (Issue #206)."""
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.user import User
+
+        # Create a test user
+        user = User(username="testuploader", password_hash="fakehash", role="user")
+        db_session.add(user)
+        await db_session.flush()
+
+        # Create a file with created_by_id set
+        lib_file = LibraryFile(
+            filename="user_uploaded.3mf",
+            file_path="/test/user_uploaded.3mf",
+            file_size=2048,
+            file_type="3mf",
+            created_by_id=user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        # Verify file detail shows username
+        response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["created_by_id"] == user.id
+        assert result["created_by_username"] == "testuploader"
+
+        # Verify file list also shows username
+        response = await async_client.get("/api/v1/library/files?include_root=false")
+        assert response.status_code == 200
+        files = response.json()
+        test_file = next((f for f in files if f["id"] == lib_file.id), None)
+        assert test_file is not None
+        assert test_file["created_by_id"] == user.id
+        assert test_file["created_by_username"] == "testuploader"
+
 
 
 class TestLibraryAddToQueueAPI:
 class TestLibraryAddToQueueAPI:
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""

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

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

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

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

+ 76 - 0
frontend/src/api/client.ts

@@ -324,6 +324,9 @@ export interface Archive {
   energy_kwh: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
   energy_cost: number | null;
   created_at: string;
   created_at: string;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
 }
 }
 
 
 export interface ArchiveStats {
 export interface ArchiveStats {
@@ -1094,6 +1097,9 @@ export interface PrintQueueItem {
   library_file_thumbnail?: string | null;
   library_file_thumbnail?: string | null;
   printer_name?: string | null;
   printer_name?: string | null;
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
   print_time_seconds?: number | null;  // Estimated print time from archive or library file
+  // User tracking (Issue #206)
+  created_by_id?: number | null;
+  created_by_username?: string | null;
 }
 }
 
 
 export interface PrintQueueItemCreate {
 export interface PrintQueueItemCreate {
@@ -1975,6 +1981,10 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  // Get current print user (for reprint tracking - Issue #206)
+  getCurrentPrintUser: (printerId: number) =>
+    request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`),
+
   // Chamber Light Control
   // Chamber Light Control
   setChamberLight: (printerId: number, on: boolean) =>
   setChamberLight: (printerId: number, on: boolean) =>
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
     request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
@@ -2223,8 +2233,13 @@ export const api = {
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2273,8 +2288,13 @@ export const api = {
     if (audioFile) {
     if (audioFile) {
       formData.append('audio', audioFile);
       formData.append('audio', audioFile);
     }
     }
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2289,7 +2309,12 @@ export const api = {
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
   uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
+      headers,
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
     });
     });
@@ -2311,8 +2336,13 @@ export const api = {
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2331,8 +2361,13 @@ export const api = {
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {
     const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2461,8 +2496,13 @@ export const api = {
     const url = printerId
     const url = printerId
       ? `${API_BASE}/archives/upload?printer_id=${printerId}`
       ? `${API_BASE}/archives/upload?printer_id=${printerId}`
       : `${API_BASE}/archives/upload`;
       : `${API_BASE}/archives/upload`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2477,8 +2517,13 @@ export const api = {
     const url = printerId
     const url = printerId
       ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
       ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
       : `${API_BASE}/archives/upload-bulk`;
       : `${API_BASE}/archives/upload-bulk`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -2525,8 +2570,13 @@ export const api = {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
     const url = `${API_BASE}/settings/restore`;
     const url = `${API_BASE}/settings/restore`;
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(url, {
     const response = await fetch(url, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     return response.json() as Promise<{
     return response.json() as Promise<{
@@ -3027,8 +3077,13 @@ export const api = {
   uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
   uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
     const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3087,8 +3142,13 @@ export const api = {
   }> => {
   }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
     const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3205,8 +3265,13 @@ export const api = {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (folderId) params.set('folder_id', String(folderId));
     if (folderId) params.set('folder_id', String(folderId));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/library/files?${params}`, {
     const response = await fetch(`${API_BASE}/library/files?${params}`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3229,8 +3294,13 @@ export const api = {
     params.set('preserve_structure', String(preserveStructure));
     params.set('preserve_structure', String(preserveStructure));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
     params.set('generate_stl_thumbnails', String(generateStlThumbnails));
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       method: 'POST',
+      headers,
       body: formData,
       body: formData,
     });
     });
     if (!response.ok) {
     if (!response.ok) {
@@ -3530,6 +3600,9 @@ export interface LibraryFile {
   notes: string | null;
   notes: string | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicate_count: number;
   duplicate_count: number;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }
@@ -3543,6 +3616,9 @@ export interface LibraryFileListItem {
   thumbnail_path: string | null;
   thumbnail_path: string | null;
   print_count: number;
   print_count: number;
   duplicate_count: number;
   duplicate_count: number;
+  // User tracking (Issue #206)
+  created_by_id: number | null;
+  created_by_username: string | null;
   created_at: string;
   created_at: string;
   print_name: string | null;
   print_name: string | null;
   print_time_seconds: number | null;
   print_time_seconds: number | null;

+ 18 - 3
frontend/src/pages/ArchivesPage.tsx

@@ -44,6 +44,7 @@ import {
   ChevronLeft,
   ChevronLeft,
   ChevronRight,
   ChevronRight,
   Settings,
   Settings,
+  User,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
@@ -886,10 +887,18 @@ function ArchiveCard({
         {/* Spacer to push content to bottom */}
         {/* Spacer to push content to bottom */}
         <div className="flex-1" />
         <div className="flex-1" />
 
 
-        {/* Date & Size */}
+        {/* Date, Size & Creator */}
         <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
         <div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
           <span>{formatDateTime(archive.created_at, timeFormat)}</span>
           <span>{formatDateTime(archive.created_at, timeFormat)}</span>
-          <span>{formatFileSize(archive.file_size)}</span>
+          <div className="flex items-center gap-2">
+            {archive.created_by_username && (
+              <span className="flex items-center gap-1" title={`Uploaded by ${archive.created_by_username}`}>
+                <User className="w-3 h-3" />
+                {archive.created_by_username}
+              </span>
+            )}
+            <span>{formatFileSize(archive.file_size)}</span>
+          </div>
         </div>
         </div>
 
 
         {/* Actions */}
         {/* Actions */}
@@ -1731,7 +1740,13 @@ function ArchiveListRow({
           {printerName}
           {printerName}
         </div>
         </div>
         <div className="col-span-2 text-sm text-bambu-gray">
         <div className="col-span-2 text-sm text-bambu-gray">
-          {formatDateOnly(archive.created_at)}
+          <div>{formatDateOnly(archive.created_at)}</div>
+          {archive.created_by_username && (
+            <div className="flex items-center gap-1 text-xs opacity-75" title={`Uploaded by ${archive.created_by_username}`}>
+              <User className="w-3 h-3" />
+              {archive.created_by_username}
+            </div>
+          )}
         </div>
         </div>
         <div className="col-span-1 text-sm text-bambu-gray">
         <div className="col-span-1 text-sm text-bambu-gray">
           {formatFileSize(archive.file_size)}
           {formatFileSize(archive.file_size)}

+ 5 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -937,6 +937,11 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             Printed {file.print_count}x
             Printed {file.print_count}x
           </div>
           </div>
         )}
         )}
+        {file.created_by_username && (
+          <div className="mt-1 text-xs text-bambu-gray">
+            Uploaded by {file.created_by_username}
+          </div>
+        )}
       </div>
       </div>
 
 
       {/* Actions - always visible on mobile, hover on desktop */}
       {/* Actions - always visible on mobile, hover on desktop */}

+ 24 - 0
frontend/src/pages/PrintersPage.tsx

@@ -40,6 +40,7 @@ import {
   ScanSearch,
   ScanSearch,
   CheckCircle,
   CheckCircle,
   XCircle,
   XCircle,
+  User,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 // Custom Skip Objects icon - arrow jumping over boxes
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -1116,6 +1117,23 @@ function PrinterCard({
   });
   });
   const queueCount = queueItems?.length || 0;
   const queueCount = queueItems?.length || 0;
 
 
+  // Fetch currently printing queue item to show who started it (Issue #206)
+  const { data: printingQueueItems } = useQuery({
+    queryKey: ['queue', printer.id, 'printing'],
+    queryFn: () => api.getQueue(printer.id, 'printing'),
+    enabled: status?.state === 'RUNNING',
+  });
+
+  // Fetch reprint user info (for prints started via Reprint, not queue - Issue #206)
+  const { data: reprintUser } = useQuery({
+    queryKey: ['currentPrintUser', printer.id],
+    queryFn: () => api.getCurrentPrintUser(printer.id),
+    enabled: status?.state === 'RUNNING',
+  });
+
+  // Combine both sources: queue item user takes precedence, then reprint user
+  const currentPrintUser = printingQueueItems?.[0]?.created_by_username || reprintUser?.username;
+
   // Fetch last completed print for this printer
   // Fetch last completed print for this printer
   const { data: lastPrints } = useQuery({
   const { data: lastPrints } = useQuery({
     queryKey: ['archives', printer.id, 'last'],
     queryKey: ['archives', printer.id, 'last'],
@@ -1896,6 +1914,12 @@ function PrinterCard({
                                 {status.layer_num}/{status.total_layers}
                                 {status.layer_num}/{status.total_layers}
                               </span>
                               </span>
                             )}
                             )}
+                            {currentPrintUser && (
+                              <span className="flex items-center gap-1" title={`Started by ${currentPrintUser}`}>
+                                <User className="w-3 h-3" />
+                                {currentPrintUser}
+                              </span>
+                            )}
                           </div>
                           </div>
                         </>
                         </>
                       ) : (
                       ) : (

+ 7 - 0
frontend/src/pages/QueuePage.tsx

@@ -44,6 +44,7 @@ import {
   Check,
   Check,
   CheckSquare,
   CheckSquare,
   Square,
   Square,
+  User,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
@@ -427,6 +428,12 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
                 {formatDuration(item.print_time_seconds)}
               </span>
               </span>
             )}
             )}
+            {item.created_by_username && (
+              <span className="flex items-center gap-1.5" title={`Added by ${item.created_by_username}`}>
+                <User className="w-3.5 h-3.5" />
+                {item.created_by_username}
+              </span>
+            )}
             {isPending && !item.manual_start && (
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
                 <Clock className="w-3.5 h-3.5" />

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CPqcJWwC.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-Pd44WL0W.js


+ 2 - 2
static/index.html

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů