Explorar o código

Implement ownership-based permissions (Issue #205)

Backend:
- Split update/delete permissions into *_own and *_all variants:
  - queue:update_own/all, queue:delete_own/all
  - archives:update_own/all, archives:delete_own/all, archives:reprint_own/all
  - library:update_own/all, library:delete_own/all
- Add require_ownership_permission dependency factory in auth.py
- Enforce ownership checks on all relevant API endpoints:
  - archives.py: PATCH, DELETE, POST /reprint
  - print_queue.py: PATCH, DELETE, POST /cancel, PATCH /bulk
  - library.py: PUT /files, DELETE /files, POST /bulk-delete, DELETE /folders
- Add user items count endpoint: GET /users/{id}/items-count
- Add delete_items parameter to DELETE /users/{id}
- Explicitly set created_by_id to NULL on user deletion for DB portability
- Add permission migration for existing groups in database.py
- Add require_permission_if_auth_enabled for folder delete

Frontend:
- Add canModify helper to AuthContext for ownership-based checks
- Update ArchivesPage: use canModify for edit/delete/reprint buttons
- Update QueuePage: use canModify for edit/delete/cancel buttons
- Update FileManagerPage: use canModify for edit/delete buttons
- Update SettingsPage: add user deletion modal with item handling options
- Update StatsPage: use archives:update_all for recalculate costs
- Update Permission type with new ownership permissions
- Add getUserItemsCount and update deleteUser API methods

Tests:
- Add test_ownership_permissions.py with 28 comprehensive tests
- Test admin *_all permissions, operator *_own permissions
- Test bulk operations skip non-owned items
- Test auth disabled allows all operations
- Test user deletion with/without items

Closes #205
maziggy hai 3 meses
pai
achega
d715132a84

+ 17 - 0
CHANGELOG.md

@@ -5,6 +5,23 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.7b] - Not released
 ## [0.1.7b] - Not released
 
 
 ### Enhancements
 ### 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):
 - **User Tracking for Archives, Library & Queue** (Issue #206):
   - Track and display who uploaded each archive file
   - Track and display who uploaded each archive file
   - Track and display who uploaded each library file (File Manager)
   - Track and display who uploaded each library file (File Manager)

+ 61 - 9
backend/app/api/routes/archives.py

@@ -8,9 +8,10 @@ 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.auth import require_auth_if_enabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -712,25 +713,42 @@ async def update_archive(
     archive_id: int,
     archive_id: int,
     update_data: ArchiveUpdate,
     update_data: ArchiveUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_UPDATE_ALL,
+            Permission.ARCHIVES_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
     """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
     from sqlalchemy.orm import selectinload
     from sqlalchemy.orm import selectinload
 
 
+    user, can_modify_all = auth_result
+
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id)
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+        .where(PrintArchive.id == archive_id)
     )
     )
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if archive.created_by_id != user.id:
+            raise HTTPException(403, "You can only update your own archives")
+
     for field, value in update_data.model_dump(exclude_unset=True).items():
     for field, value in update_data.model_dump(exclude_unset=True).items():
         setattr(archive, field, value)
         setattr(archive, field, value)
 
 
     await db.commit()
     await db.commit()
 
 
-    # Re-fetch with project relationship loaded after commit
+    # Re-fetch with relationships loaded after commit
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive).options(selectinload(PrintArchive.project)).where(PrintArchive.id == archive_id)
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
+        .where(PrintArchive.id == archive_id)
     )
     )
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
 
 
@@ -933,8 +951,30 @@ async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.delete("/{archive_id}")
 @router.delete("/{archive_id}")
-async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_DELETE_ALL,
+            Permission.ARCHIVES_DELETE_OWN,
+        )
+    ),
+):
     """Delete an archive."""
     """Delete an archive."""
+    user, can_modify_all = auth_result
+
+    # Get archive first to check ownership
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    # Ownership check
+    if not can_modify_all:
+        if archive.created_by_id != user.id:
+            raise HTTPException(403, "You can only delete your own archives")
+
     service = ArchiveService(db)
     service = ArchiveService(db)
     if not await service.delete_archive(archive_id):
     if not await service.delete_archive(archive_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
@@ -2433,7 +2473,12 @@ async def reprint_archive(
     printer_id: int,
     printer_id: int,
     body: ReprintRequest | None = None,
     body: ReprintRequest | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_REPRINT_ALL,
+            Permission.ARCHIVES_REPRINT_OWN,
+        )
+    ),
 ):
 ):
     """Send an archived 3MF file to a printer and start printing."""
     """Send an archived 3MF file to a printer and start printing."""
     from backend.app.main import register_expected_print
     from backend.app.main import register_expected_print
@@ -2445,6 +2490,8 @@ async def reprint_archive(
     )
     )
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
+    user, can_modify_all = auth_result
+
     # Use defaults if no body provided
     # Use defaults if no body provided
     if body is None:
     if body is None:
         body = ReprintRequest()
         body = ReprintRequest()
@@ -2455,6 +2502,11 @@ async def reprint_archive(
     if not archive:
     if not archive:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if archive.created_by_id != user.id:
+            raise HTTPException(403, "You can only reprint your own archives")
+
     # Get printer
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -2566,9 +2618,9 @@ async def reprint_archive(
         raise HTTPException(500, "Failed to start print")
         raise HTTPException(500, "Failed to start print")
 
 
     # Track who started this print (Issue #206)
     # 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}")
+    if user:
+        printer_manager.set_current_print_user(printer_id, user.id, user.username)
+        logger.info(f"Reprint started by user: {user.username}")
 
 
     return {
     return {
         "status": "printing",
         "status": "printing",

+ 77 - 7
backend/app/api/routes/library.py

@@ -15,9 +15,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.auth import (
+    require_auth_if_enabled,
+    require_ownership_permission,
+    require_permission_if_auth_enabled,
+)
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
@@ -527,8 +532,16 @@ async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = D
 
 
 
 
 @router.delete("/folders/{folder_id}")
 @router.delete("/folders/{folder_id}")
-async def delete_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
-    """Delete a folder and all its contents (cascade)."""
+async def delete_folder(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
+):
+    """Delete a folder and all its contents (cascade).
+
+    Note: Folders require library:delete_all permission since they don't have
+    ownership tracking.
+    """
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
     folder = result.scalar_one_or_none()
 
 
@@ -1825,14 +1838,31 @@ async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.put("/files/{file_id}", response_model=FileResponseSchema)
 @router.put("/files/{file_id}", response_model=FileResponseSchema)
-async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends(get_db)):
+async def update_file(
+    file_id: int,
+    data: FileUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
     """Update a file's metadata."""
     """Update a file's metadata."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only update your own files")
+
     if data.filename is not None:
     if data.filename is not None:
         # Validate filename doesn't contain path separators
         # Validate filename doesn't contain path separators
         if "/" in data.filename or "\\" in data.filename:
         if "/" in data.filename or "\\" in data.filename:
@@ -1870,14 +1900,30 @@ async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends
 
 
 
 
 @router.delete("/files/{file_id}")
 @router.delete("/files/{file_id}")
-async def delete_file(file_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
     """Delete a file."""
     """Delete a file."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
     file = result.scalar_one_or_none()
 
 
     if not file:
     if not file:
         raise HTTPException(status_code=404, detail="File not found")
         raise HTTPException(status_code=404, detail="File not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only delete your own files")
+
     # Delete actual files
     # Delete actual files
     try:
     try:
         abs_file_path = to_absolute_path(file.file_path)
         abs_file_path = to_absolute_path(file.file_path)
@@ -2004,16 +2050,35 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
-async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db)):
-    """Delete multiple files and/or folders."""
+async def bulk_delete(
+    data: BulkDeleteRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
+):
+    """Delete multiple files and/or folders.
+
+    Files not owned by the user are skipped (unless user has *_all permission).
+    """
+    user, can_modify_all = auth_result
     deleted_files = 0
     deleted_files = 0
     deleted_folders = 0
     deleted_folders = 0
+    skipped_files = 0
 
 
     # Delete files first
     # Delete files first
     for file_id in data.file_ids:
     for file_id in data.file_ids:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
         file = result.scalar_one_or_none()
         if file:
         if file:
+            # Ownership check
+            if not can_modify_all and file.created_by_id != user.id:
+                skipped_files += 1
+                continue
+
             try:
             try:
                 abs_file_path = to_absolute_path(file.file_path)
                 abs_file_path = to_absolute_path(file.file_path)
                 abs_thumb_path = to_absolute_path(file.thumbnail_path)
                 abs_thumb_path = to_absolute_path(file.thumbnail_path)
@@ -2027,7 +2092,12 @@ async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db
             deleted_files += 1
             deleted_files += 1
 
 
     # Delete folders (cascade will handle contents)
     # Delete folders (cascade will handle contents)
+    # Note: Folders don't have ownership tracking currently, require *_all permission
     for folder_id in data.folder_ids:
     for folder_id in data.folder_ids:
+        if not can_modify_all:
+            # Users without *_all permission cannot delete folders
+            continue
+
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder = result.scalar_one_or_none()
         folder = result.scalar_one_or_none()
         if folder:
         if folder:

+ 65 - 4
backend/app/api/routes/print_queue.py

@@ -12,9 +12,10 @@ 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.auth import require_auth_if_enabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.print_queue import PrintQueueItem
@@ -361,11 +362,20 @@ async def add_to_queue(
 async def bulk_update_queue_items(
 async def bulk_update_queue_items(
     data: PrintQueueBulkUpdate,
     data: PrintQueueBulkUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Bulk update multiple queue items with the same values.
     """Bulk update multiple queue items with the same values.
 
 
     Only pending items can be updated. Non-pending items are skipped.
     Only pending items can be updated. Non-pending items are skipped.
+    Items not owned by the user are also skipped (unless user has *_all permission).
     """
     """
+    user, can_modify_all = auth_result
+
     if not data.item_ids:
     if not data.item_ids:
         raise HTTPException(400, "No item IDs provided")
         raise HTTPException(400, "No item IDs provided")
 
 
@@ -392,6 +402,11 @@ async def bulk_update_queue_items(
             skipped_count += 1
             skipped_count += 1
             continue
             continue
 
 
+        # Ownership check
+        if not can_modify_all and item.created_by_id != user.id:
+            skipped_count += 1
+            continue
+
         for field, value in update_data.items():
         for field, value in update_data.items():
             setattr(item, field, value)
             setattr(item, field, value)
         updated_count += 1
         updated_count += 1
@@ -402,7 +417,8 @@ async def bulk_update_queue_items(
     return PrintQueueBulkUpdateResponse(
     return PrintQueueBulkUpdateResponse(
         updated_count=updated_count,
         updated_count=updated_count,
         skipped_count=skipped_count,
         skipped_count=skipped_count,
-        message=f"Updated {updated_count} items" + (f", skipped {skipped_count} non-pending" if skipped_count else ""),
+        message=f"Updated {updated_count} items"
+        + (f", skipped {skipped_count} non-pending/not-owned" if skipped_count else ""),
     )
     )
 
 
 
 
@@ -430,13 +446,26 @@ async def update_queue_item(
     item_id: int,
     item_id: int,
     data: PrintQueueItemUpdate,
     data: PrintQueueItemUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
 ):
     """Update a queue item."""
     """Update a queue item."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if item.created_by_id != user.id:
+            raise HTTPException(403, "You can only update your own queue items")
+
     if item.status != "pending":
     if item.status != "pending":
         raise HTTPException(400, "Can only update pending items")
         raise HTTPException(400, "Can only update pending items")
 
 
@@ -485,13 +514,29 @@ async def update_queue_item(
 
 
 
 
 @router.delete("/{item_id}")
 @router.delete("/{item_id}")
-async def delete_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_DELETE_ALL,
+            Permission.QUEUE_DELETE_OWN,
+        )
+    ),
+):
     """Remove an item from the queue."""
     """Remove an item from the queue."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if item.created_by_id != user.id:
+            raise HTTPException(403, "You can only delete your own queue items")
+
     if item.status == "printing":
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
 
@@ -520,13 +565,29 @@ async def reorder_queue(
 
 
 
 
 @router.post("/{item_id}/cancel")
 @router.post("/{item_id}/cancel")
-async def cancel_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+async def cancel_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
+):
     """Cancel a pending queue item."""
     """Cancel a pending queue item."""
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
     if not item:
     if not item:
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
+    # Ownership check
+    if not can_modify_all:
+        if item.created_by_id != user.id:
+            raise HTTPException(403, "You can only cancel your own queue items")
+
     if item.status not in ("pending",):
     if item.status not in ("pending",):
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
 
 

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

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

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

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

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

@@ -1114,6 +1114,8 @@ async def seed_default_groups():
     don't exist, then migrates existing users:
     don't exist, then migrates existing users:
     - Users with role='admin' -> Administrators group
     - Users with role='admin' -> Administrators group
     - Users with role='user' -> Operators group
     - Users with role='user' -> Operators group
+
+    Also migrates old permissions to new ownership-based permissions (Issue #205).
     """
     """
     import logging
     import logging
 
 
@@ -1125,10 +1127,32 @@ async def seed_default_groups():
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
+    # Map old permissions to new ones for migration
+    # Administrators get *_all permissions, Operators get *_own permissions
+    PERMISSION_MIGRATION_ALL = {
+        "queue:update": "queue:update_all",
+        "queue:delete": "queue:delete_all",
+        "archives:update": "archives:update_all",
+        "archives:delete": "archives:delete_all",
+        "archives:reprint": "archives:reprint_all",
+        "library:update": "library:update_all",
+        "library:delete": "library:delete_all",
+    }
+
+    PERMISSION_MIGRATION_OWN = {
+        "queue:update": "queue:update_own",
+        "queue:delete": "queue:delete_own",
+        "archives:update": "archives:update_own",
+        "archives:delete": "archives:delete_own",
+        "archives:reprint": "archives:reprint_own",
+        "library:update": "library:update_own",
+        "library:delete": "library:delete_own",
+    }
+
     async with async_session() as session:
     async with async_session() as session:
         # Get existing groups
         # Get existing groups
-        result = await session.execute(select(Group.name))
-        existing_groups = {row[0] for row in result.fetchall()}
+        result = await session.execute(select(Group))
+        existing_groups = {group.name: group for group in result.scalars().all()}
 
 
         # Create default groups if they don't exist
         # Create default groups if they don't exist
         groups_created = []
         groups_created = []
@@ -1143,12 +1167,50 @@ async def seed_default_groups():
                 session.add(group)
                 session.add(group)
                 groups_created.append(group_name)
                 groups_created.append(group_name)
                 logger.info(f"Created default group: {group_name}")
                 logger.info(f"Created default group: {group_name}")
+            else:
+                # Migrate existing group's permissions from old to new format
+                group = existing_groups[group_name]
+                if group.permissions:
+                    updated = False
+                    new_permissions = list(group.permissions)
+
+                    # Determine which migration map to use based on group
+                    migration_map = (
+                        PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN
+                    )
+
+                    for old_perm, new_perm in migration_map.items():
+                        if old_perm in new_permissions:
+                            new_permissions.remove(old_perm)
+                            if new_perm not in new_permissions:
+                                new_permissions.append(new_perm)
+                            updated = True
+                            logger.info(f"Migrated permission '{old_perm}' to '{new_perm}' in group '{group_name}'")
+
+                    # For Administrators, also ensure they get *_all permissions if they have any new *_own
+                    if group_name == "Administrators":
+                        for _own_perm, all_perm in [
+                            ("queue:update_own", "queue:update_all"),
+                            ("queue:delete_own", "queue:delete_all"),
+                            ("archives:update_own", "archives:update_all"),
+                            ("archives:delete_own", "archives:delete_all"),
+                            ("archives:reprint_own", "archives:reprint_all"),
+                            ("library:update_own", "library:update_all"),
+                            ("library:delete_own", "library:delete_all"),
+                        ]:
+                            # Add *_all if not present
+                            if all_perm not in new_permissions:
+                                new_permissions.append(all_perm)
+                                updated = True
+
+                    if updated:
+                        group.permissions = new_permissions
 
 
         await session.commit()
         await session.commit()
 
 
         # Migrate existing users to groups if they're not already in any group
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
         if groups_created:
-            # Get the groups we need
+            # Refresh to get newly created groups
             admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
             admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
             admin_group = admin_result.scalar_one_or_none()
             admin_group = admin_result.scalar_one_or_none()
 
 

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

@@ -25,22 +25,29 @@ class Permission(str, Enum):
     # Archives
     # Archives
     ARCHIVES_READ = "archives:read"
     ARCHIVES_READ = "archives:read"
     ARCHIVES_CREATE = "archives:create"
     ARCHIVES_CREATE = "archives:create"
-    ARCHIVES_UPDATE = "archives:update"
-    ARCHIVES_DELETE = "archives:delete"
-    ARCHIVES_REPRINT = "archives:reprint"  # Reprint from archive
+    ARCHIVES_UPDATE_OWN = "archives:update_own"
+    ARCHIVES_UPDATE_ALL = "archives:update_all"
+    ARCHIVES_DELETE_OWN = "archives:delete_own"
+    ARCHIVES_DELETE_ALL = "archives:delete_all"
+    ARCHIVES_REPRINT_OWN = "archives:reprint_own"
+    ARCHIVES_REPRINT_ALL = "archives:reprint_all"
 
 
     # Queue
     # Queue
     QUEUE_READ = "queue:read"
     QUEUE_READ = "queue:read"
     QUEUE_CREATE = "queue:create"
     QUEUE_CREATE = "queue:create"
-    QUEUE_UPDATE = "queue:update"
-    QUEUE_DELETE = "queue:delete"
+    QUEUE_UPDATE_OWN = "queue:update_own"
+    QUEUE_UPDATE_ALL = "queue:update_all"
+    QUEUE_DELETE_OWN = "queue:delete_own"
+    QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_REORDER = "queue:reorder"
     QUEUE_REORDER = "queue:reorder"
 
 
     # Library
     # Library
     LIBRARY_READ = "library:read"
     LIBRARY_READ = "library:read"
     LIBRARY_UPLOAD = "library:upload"
     LIBRARY_UPLOAD = "library:upload"
-    LIBRARY_UPDATE = "library:update"
-    LIBRARY_DELETE = "library:delete"
+    LIBRARY_UPDATE_OWN = "library:update_own"
+    LIBRARY_UPDATE_ALL = "library:update_all"
+    LIBRARY_DELETE_OWN = "library:delete_own"
+    LIBRARY_DELETE_ALL = "library:delete_all"
 
 
     # Projects
     # Projects
     PROJECTS_READ = "projects:read"
     PROJECTS_READ = "projects:read"
@@ -156,22 +163,29 @@ PERMISSION_CATEGORIES = {
     "Archives": [
     "Archives": [
         Permission.ARCHIVES_READ,
         Permission.ARCHIVES_READ,
         Permission.ARCHIVES_CREATE,
         Permission.ARCHIVES_CREATE,
-        Permission.ARCHIVES_UPDATE,
-        Permission.ARCHIVES_DELETE,
-        Permission.ARCHIVES_REPRINT,
+        Permission.ARCHIVES_UPDATE_OWN,
+        Permission.ARCHIVES_UPDATE_ALL,
+        Permission.ARCHIVES_DELETE_OWN,
+        Permission.ARCHIVES_DELETE_ALL,
+        Permission.ARCHIVES_REPRINT_OWN,
+        Permission.ARCHIVES_REPRINT_ALL,
     ],
     ],
     "Queue": [
     "Queue": [
         Permission.QUEUE_READ,
         Permission.QUEUE_READ,
         Permission.QUEUE_CREATE,
         Permission.QUEUE_CREATE,
-        Permission.QUEUE_UPDATE,
-        Permission.QUEUE_DELETE,
+        Permission.QUEUE_UPDATE_OWN,
+        Permission.QUEUE_UPDATE_ALL,
+        Permission.QUEUE_DELETE_OWN,
+        Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_REORDER,
         Permission.QUEUE_REORDER,
     ],
     ],
     "Library": [
     "Library": [
         Permission.LIBRARY_READ,
         Permission.LIBRARY_READ,
         Permission.LIBRARY_UPLOAD,
         Permission.LIBRARY_UPLOAD,
-        Permission.LIBRARY_UPDATE,
-        Permission.LIBRARY_DELETE,
+        Permission.LIBRARY_UPDATE_OWN,
+        Permission.LIBRARY_UPDATE_ALL,
+        Permission.LIBRARY_DELETE_OWN,
+        Permission.LIBRARY_DELETE_ALL,
     ],
     ],
     "Projects": [
     "Projects": [
         Permission.PROJECTS_READ,
         Permission.PROJECTS_READ,
@@ -291,23 +305,23 @@ DEFAULT_GROUPS = {
             Permission.PRINTERS_DELETE.value,
             Permission.PRINTERS_DELETE.value,
             Permission.PRINTERS_CONTROL.value,
             Permission.PRINTERS_CONTROL.value,
             Permission.PRINTERS_FILES.value,
             Permission.PRINTERS_FILES.value,
-            # Archives - full access
+            # Archives - own items only
             Permission.ARCHIVES_READ.value,
             Permission.ARCHIVES_READ.value,
             Permission.ARCHIVES_CREATE.value,
             Permission.ARCHIVES_CREATE.value,
-            Permission.ARCHIVES_UPDATE.value,
-            Permission.ARCHIVES_DELETE.value,
-            Permission.ARCHIVES_REPRINT.value,
-            # Queue - full access
+            Permission.ARCHIVES_UPDATE_OWN.value,
+            Permission.ARCHIVES_DELETE_OWN.value,
+            Permission.ARCHIVES_REPRINT_OWN.value,
+            # Queue - own items only
             Permission.QUEUE_READ.value,
             Permission.QUEUE_READ.value,
             Permission.QUEUE_CREATE.value,
             Permission.QUEUE_CREATE.value,
-            Permission.QUEUE_UPDATE.value,
-            Permission.QUEUE_DELETE.value,
+            Permission.QUEUE_UPDATE_OWN.value,
+            Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
             Permission.QUEUE_REORDER.value,
-            # Library - full access
+            # Library - own items only
             Permission.LIBRARY_READ.value,
             Permission.LIBRARY_READ.value,
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPLOAD.value,
-            Permission.LIBRARY_UPDATE.value,
-            Permission.LIBRARY_DELETE.value,
+            Permission.LIBRARY_UPDATE_OWN.value,
+            Permission.LIBRARY_DELETE_OWN.value,
             # Projects - full access
             # Projects - full access
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_CREATE.value,
             Permission.PROJECTS_CREATE.value,

+ 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

+ 12 - 5
frontend/src/api/client.ts

@@ -1728,9 +1728,14 @@ export interface ExternalLinkUpdate {
 // Permission type - all available permissions
 // Permission type - all available permissions
 export type Permission =
 export type Permission =
   | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files'
   | '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'
+  | 'archives:read' | 'archives:create'
+  | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
+  | 'archives:reprint_own' | 'archives:reprint_all'
+  | 'queue:read' | 'queue:create'
+  | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
+  | 'queue:reorder'
+  | 'library:read' | 'library:upload'
+  | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
@@ -1892,10 +1897,12 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
     }),
     }),
-  deleteUser: (id: number) =>
-    request<void>(`/users/${id}`, {
+  deleteUser: (id: number, deleteItems: boolean = false) =>
+    request<void>(`/users/${id}?delete_items=${deleteItems}`, {
       method: 'DELETE',
       method: 'DELETE',
     }),
     }),
+  getUserItemsCount: (id: number) =>
+    request<{ archives: number; queue_items: number; library_files: number }>(`/users/${id}/items-count`),
   changePassword: (currentPassword: string, newPassword: string) =>
   changePassword: (currentPassword: string, newPassword: string) =>
     request<{ message: string }>('/users/me/change-password', {
     request<{ message: string }>('/users/me/change-password', {
       method: 'POST',
       method: 'POST',

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

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

+ 70 - 70
frontend/src/pages/ArchivesPage.tsx

@@ -124,7 +124,7 @@ function ArchiveCard({
 
 
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -287,8 +287,8 @@ function ArchiveCard({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
-        disabled: !hasPermission('archives:reprint'),
-        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
+        disabled: !canModify('archives', 'reprint', archive.created_by_id),
+        title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
@@ -342,8 +342,8 @@ function ArchiveCard({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       onClick: () => timelapseScanMutation.mutate(),
-      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined,
     },
     },
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -359,30 +359,30 @@ function ArchiveCard({
           source3mfInputRef.current?.click();
           source3mfInputRef.current?.click();
         }
         }
       },
       },
-      disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
-      title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
+      disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),
+      title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined,
     },
     },
     ...(archive.source_3mf_path ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       onClick: () => source3mfInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Remove Source 3MF',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     {
     {
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -399,8 +399,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -450,15 +450,15 @@ function ArchiveCard({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -469,8 +469,8 @@ function ArchiveCard({
       label: 'Add to Project',
       label: 'Add to Project',
       icon: <FolderKanban className="w-4 h-4" />,
       icon: <FolderKanban className="w-4 h-4" />,
       onClick: () => {},
       onClick: () => {},
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
       submenu: (() => {
       submenu: (() => {
         const items: ContextMenuItem[] = [];
         const items: ContextMenuItem[] = [];
 
 
@@ -480,7 +480,7 @@ function ArchiveCard({
             label: 'Remove from Project',
             label: 'Remove from Project',
             icon: <X className="w-4 h-4" />,
             icon: <X className="w-4 h-4" />,
             onClick: () => assignProjectMutation.mutate(null),
             onClick: () => assignProjectMutation.mutate(null),
-            disabled: !hasPermission('archives:update'),
+            disabled: !canModify('archives', 'update', archive.created_by_id),
           });
           });
         }
         }
 
 
@@ -507,7 +507,7 @@ function ArchiveCard({
                 label: p.name,
                 label: p.name,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 onClick: () => assignProjectMutation.mutate(p.id),
                 onClick: () => assignProjectMutation.mutate(p.id),
-                disabled: archive.project_id === p.id || !hasPermission('archives:update'),
+                disabled: archive.project_id === p.id || !canModify('archives', 'update', archive.created_by_id),
               });
               });
             });
             });
           }
           }
@@ -527,8 +527,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:delete'),
-      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
+      disabled: !canModify('archives', 'delete', archive.created_by_id),
+      title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined,
     },
     },
   ];
   ];
 
 
@@ -649,21 +649,21 @@ function ArchiveCard({
         {/* Favorite star */}
         {/* Favorite star */}
         <button
         <button
           className={`absolute top-2 right-2 p-1 rounded transition-colors ${
           className={`absolute top-2 right-2 p-1 rounded transition-colors ${
-            hasPermission('archives:update')
+            canModify('archives', 'update', archive.created_by_id)
               ? 'bg-black/50 hover:bg-black/70'
               ? 'bg-black/50 hover:bg-black/70'
               : 'bg-black/30 cursor-not-allowed'
               : 'bg-black/30 cursor-not-allowed'
           }`}
           }`}
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
-            if (hasPermission('archives:update')) {
+            if (canModify('archives', 'update', archive.created_by_id)) {
               favoriteMutation.mutate();
               favoriteMutation.mutate();
             }
             }
           }}
           }}
-          disabled={!hasPermission('archives:update')}
-          title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
+          disabled={!canModify('archives', 'update', archive.created_by_id)}
+          title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
         >
         >
           <Star
           <Star
-            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!hasPermission('archives:update') ? 'opacity-50' : ''}`}
+            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!canModify('archives', 'update', archive.created_by_id) ? 'opacity-50' : ''}`}
           />
           />
         </button>
         </button>
         {(archive.status === 'failed' || archive.status === 'aborted') && (
         {(archive.status === 'failed' || archive.status === 'aborted') && (
@@ -911,8 +911,8 @@ function ArchiveCard({
                 size="sm"
                 size="sm"
                 className="flex-1 min-w-0"
                 className="flex-1 min-w-0"
                 onClick={() => setShowReprint(true)}
                 onClick={() => setShowReprint(true)}
-                disabled={!hasPermission('archives:reprint')}
-                title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined}
+                disabled={!canModify('archives', 'reprint', archive.created_by_id)}
+                title={!canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint' : undefined}
               >
               >
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <span className="hidden sm:inline">Reprint</span>
                 <span className="hidden sm:inline">Reprint</span>
@@ -1006,8 +1006,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
+            disabled={!canModify('archives', 'update', archive.created_by_id)}
+            title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
           </Button>
           </Button>
@@ -1016,8 +1016,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            disabled={!hasPermission('archives:delete')}
-            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
+            disabled={!canModify('archives', 'delete', archive.created_by_id)}
+            title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
           </Button>
           </Button>
@@ -1274,7 +1274,7 @@ function ArchiveListRow({
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, canModify } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -1419,8 +1419,8 @@ function ArchiveListRow({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
-        disabled: !hasPermission('archives:reprint'),
-        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
+        disabled: !canModify('archives', 'reprint', archive.created_by_id),
+        title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
@@ -1474,8 +1474,8 @@ function ArchiveListRow({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       onClick: () => timelapseScanMutation.mutate(),
-      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined,
     },
     },
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1491,30 +1491,30 @@ function ArchiveListRow({
           source3mfInputRef.current?.click();
           source3mfInputRef.current?.click();
         }
         }
       },
       },
-      disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
-      title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
+      disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id),
+      title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined,
     },
     },
     ...(archive.source_3mf_path ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       onClick: () => source3mfInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Remove Source 3MF',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     {
     {
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -1531,8 +1531,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1582,15 +1582,15 @@ function ArchiveListRow({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
-      disabled: !hasPermission('archives:update'),
-      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
+      disabled: !canModify('archives', 'update', archive.created_by_id),
+      title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined,
     },
     },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -1651,8 +1651,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
-      disabled: !hasPermission('archives:delete'),
-      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
+      disabled: !canModify('archives', 'delete', archive.created_by_id),
+      title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined,
     },
     },
   ];
   ];
 
 
@@ -1791,8 +1791,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
+            disabled={!canModify('archives', 'update', archive.created_by_id)}
+            title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-4 h-4" />
             <Pencil className="w-4 h-4" />
           </Button>
           </Button>
@@ -1800,8 +1800,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            disabled={!hasPermission('archives:delete')}
-            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
+            disabled={!canModify('archives', 'delete', archive.created_by_id)}
+            title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-4 h-4 text-red-400" />
             <Trash2 className="w-4 h-4 text-red-400" />
           </Button>
           </Button>
@@ -2055,7 +2055,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 export function ArchivesPage() {
 export function ArchivesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission } = useAuth();
   const searchInputRef = useRef<HTMLInputElement>(null);
   const searchInputRef = useRef<HTMLInputElement>(null);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
@@ -2471,8 +2471,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchTag(true)}
             onClick={() => setShowBatchTag(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
+            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
+            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <Tag className="w-4 h-4" />
             <Tag className="w-4 h-4" />
             Tags
             Tags
@@ -2481,8 +2481,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchProject(true)}
             onClick={() => setShowBatchProject(true)}
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
+            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
+            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <FolderKanban className="w-4 h-4" />
             <FolderKanban className="w-4 h-4" />
             Project
             Project
@@ -2490,8 +2490,8 @@ export function ArchivesPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
-            disabled={!hasPermission('archives:update')}
-            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
+            disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')}
+            title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined}
             onClick={() => {
             onClick={() => {
               const ids = Array.from(selectedIds);
               const ids = Array.from(selectedIds);
               Promise.all(ids.map(id => api.toggleFavorite(id)))
               Promise.all(ids.map(id => api.toggleFavorite(id)))
@@ -2511,8 +2511,8 @@ export function ArchivesPage() {
             size="sm"
             size="sm"
             className="bg-red-500 hover:bg-red-600"
             className="bg-red-500 hover:bg-red-600"
             onClick={() => setShowBulkDeleteConfirm(true)}
             onClick={() => setShowBulkDeleteConfirm(true)}
-            disabled={!hasPermission('archives:delete')}
-            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined}
+            disabled={!hasAnyPermission('archives:delete_own', 'archives:delete_all')}
+            title={!hasAnyPermission('archives:delete_own', 'archives:delete_all') ? 'You do not have permission to delete archives' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Delete
             Delete

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

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

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

@@ -280,6 +280,7 @@ function SortableQueueItem({
   isSelected = false,
   isSelected = false,
   onToggleSelect,
   onToggleSelect,
   hasPermission,
   hasPermission,
+  canModify,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
   position?: number;
   position?: number;
@@ -293,6 +294,7 @@ function SortableQueueItem({
   isSelected?: boolean;
   isSelected?: boolean;
   onToggleSelect?: () => void;
   onToggleSelect?: () => void;
   hasPermission: (permission: Permission) => boolean;
   hasPermission: (permission: Permission) => boolean;
+  canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
 }) {
 }) {
   const canReorder = hasPermission('queue:reorder');
   const canReorder = hasPermission('queue:reorder');
   const {
   const {
@@ -525,8 +527,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onEdit}
                 onClick={onEdit}
-                disabled={!hasPermission('queue:update')}
-                title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : 'Edit'}
+                disabled={!canModify('queue', 'update', item.created_by_id)}
+                title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'}
               >
               >
                 <Pencil className="w-4 h-4" />
                 <Pencil className="w-4 h-4" />
               </Button>
               </Button>
@@ -534,8 +536,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onCancel}
                 onClick={onCancel}
-                disabled={!hasPermission('queue:delete')}
-                title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : 'Cancel'}
+                disabled={!canModify('queue', 'delete', item.created_by_id)}
+                title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'}
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               >
               >
                 <X className="w-4 h-4" />
                 <X className="w-4 h-4" />
@@ -558,8 +560,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onRemove}
                 onClick={onRemove}
-                disabled={!hasPermission('queue:delete')}
-                title={!hasPermission('queue:delete') ? 'You do not have permission to remove queue items' : 'Remove'}
+                disabled={!canModify('queue', 'delete', item.created_by_id)}
+                title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'}
               >
               >
                 <Trash2 className="w-4 h-4" />
                 <Trash2 className="w-4 h-4" />
               </Button>
               </Button>
@@ -574,7 +576,7 @@ function SortableQueueItem({
 export function QueuePage() {
 export function QueuePage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission } = useAuth();
+  const { hasPermission, hasAnyPermission, canModify } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
@@ -928,8 +930,8 @@ export function QueuePage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
             onClick={() => setShowClearHistoryConfirm(true)}
-            disabled={!hasPermission('queue:delete')}
-            title={!hasPermission('queue:delete') ? 'You do not have permission to clear history' : undefined}
+            disabled={!hasPermission('queue:delete_all')}
+            title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Clear History
             Clear History
@@ -970,6 +972,7 @@ export function QueuePage() {
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1039,8 +1042,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
                       onClick={() => setShowBulkEditModal(true)}
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
-                      disabled={!hasPermission('queue:update')}
-                      title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : undefined}
+                      disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
+                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined}
                     >
                     >
                       <Pencil className="w-4 h-4" />
                       <Pencil className="w-4 h-4" />
                       Edit Selected
                       Edit Selected
@@ -1050,8 +1053,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
-                      disabled={bulkCancelMutation.isPending || !hasPermission('queue:delete')}
-                      title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : undefined}
+                      disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
+                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined}
                     >
                     >
                       <X className="w-4 h-4" />
                       <X className="w-4 h-4" />
                       Cancel Selected
                       Cancel Selected
@@ -1085,6 +1088,7 @@ export function QueuePage() {
                         isSelected={selectedItems.includes(item.id)}
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         hasPermission={hasPermission}
                         hasPermission={hasPermission}
+                        canModify={canModify}
                       />
                       />
                     ))}
                     ))}
                   </div>
                   </div>
@@ -1139,6 +1143,7 @@ export function QueuePage() {
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     hasPermission={hasPermission}
+                    canModify={canModify}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>

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

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

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

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-D56fK0KZ.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- 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-Pd44WL0W.js"></script>
+    <script type="module" crossorigin src="/assets/index-D56fK0KZ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   </head>
   <body>
   <body>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio