diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index e3f9f7a4658..222edc7959f 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -4,7 +4,7 @@ from fastapi.routing import APIRouter from pydantic import BaseModel -from invokeai.app.api.auth_dependencies import CurrentUser +from invokeai.app.api.auth_dependencies import AdminUser, CurrentUser from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus from invokeai.app.services.session_queue.session_queue_common import ( @@ -177,9 +177,10 @@ async def get_queue_items_by_item_ids( responses={200: {"model": SessionProcessorStatus}}, ) async def resume( + current_user: AdminUser, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: - """Resumes session processor""" + """Resumes session processor. Admin only.""" try: return ApiDependencies.invoker.services.session_processor.resume() except Exception as e: @@ -192,9 +193,10 @@ async def resume( responses={200: {"model": SessionProcessorStatus}}, ) async def Pause( + current_user: AdminUser, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: - """Pauses session processor""" + """Pauses session processor. Admin only.""" try: return ApiDependencies.invoker.services.session_processor.pause() except Exception as e: @@ -207,11 +209,16 @@ async def Pause( responses={200: {"model": CancelAllExceptCurrentResult}}, ) async def cancel_all_except_current( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> CancelAllExceptCurrentResult: - """Immediately cancels all queue items except in-processing items""" + """Immediately cancels all queue items except in-processing items. Non-admin users can only cancel their own items.""" try: - return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id) + # Admin users can cancel all items, non-admin users can only cancel their own + user_id = None if current_user.is_admin else current_user.user_id + return ApiDependencies.invoker.services.session_queue.cancel_all_except_current( + queue_id=queue_id, user_id=user_id + ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling all except current: {e}") @@ -222,11 +229,16 @@ async def cancel_all_except_current( responses={200: {"model": DeleteAllExceptCurrentResult}}, ) async def delete_all_except_current( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> DeleteAllExceptCurrentResult: - """Immediately deletes all queue items except in-processing items""" + """Immediately deletes all queue items except in-processing items. Non-admin users can only delete their own items.""" try: - return ApiDependencies.invoker.services.session_queue.delete_all_except_current(queue_id=queue_id) + # Admin users can delete all items, non-admin users can only delete their own + user_id = None if current_user.is_admin else current_user.user_id + return ApiDependencies.invoker.services.session_queue.delete_all_except_current( + queue_id=queue_id, user_id=user_id + ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while deleting all except current: {e}") @@ -237,13 +249,16 @@ async def delete_all_except_current( responses={200: {"model": CancelByBatchIDsResult}}, ) async def cancel_by_batch_ids( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), batch_ids: list[str] = Body(description="The list of batch_ids to cancel all queue items for", embed=True), ) -> CancelByBatchIDsResult: - """Immediately cancels all queue items from the given batch ids""" + """Immediately cancels all queue items from the given batch ids. Non-admin users can only cancel their own items.""" try: + # Admin users can cancel all items, non-admin users can only cancel their own + user_id = None if current_user.is_admin else current_user.user_id return ApiDependencies.invoker.services.session_queue.cancel_by_batch_ids( - queue_id=queue_id, batch_ids=batch_ids + queue_id=queue_id, batch_ids=batch_ids, user_id=user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by batch id: {e}") @@ -255,13 +270,16 @@ async def cancel_by_batch_ids( responses={200: {"model": CancelByDestinationResult}}, ) async def cancel_by_destination( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), destination: str = Query(description="The destination to cancel all queue items for"), ) -> CancelByDestinationResult: - """Immediately cancels all queue items with the given origin""" + """Immediately cancels all queue items with the given destination. Non-admin users can only cancel their own items.""" try: + # Admin users can cancel all items, non-admin users can only cancel their own + user_id = None if current_user.is_admin else current_user.user_id return ApiDependencies.invoker.services.session_queue.cancel_by_destination( - queue_id=queue_id, destination=destination + queue_id=queue_id, destination=destination, user_id=user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by destination: {e}") @@ -273,12 +291,28 @@ async def cancel_by_destination( responses={200: {"model": RetryItemsResult}}, ) async def retry_items_by_id( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body(description="The queue item ids to retry"), ) -> RetryItemsResult: - """Immediately cancels all queue items with the given origin""" + """Retries the given queue items. Users can only retry their own items unless they are an admin.""" try: + # Check authorization: user must own all items or be an admin + if not current_user.is_admin: + for item_id in item_ids: + try: + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + if queue_item.user_id != current_user.user_id: + raise HTTPException( + status_code=403, detail=f"You do not have permission to retry queue item {item_id}" + ) + except SessionQueueItemNotFoundError: + # Skip items that don't exist - they will be handled by retry_items_by_id + continue + return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while retrying queue items: {e}") @@ -291,15 +325,23 @@ async def retry_items_by_id( }, ) async def clear( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> ClearResult: - """Clears the queue entirely, immediately canceling the currently-executing session""" + """Clears the queue entirely. If there's a currently-executing item, users can only cancel it if they own it or are an admin.""" try: queue_item = ApiDependencies.invoker.services.session_queue.get_current(queue_id) if queue_item is not None: + # Check authorization for canceling the current item + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException( + status_code=403, detail="You do not have permission to cancel the currently executing queue item" + ) ApiDependencies.invoker.services.session_queue.cancel_queue_item(queue_item.item_id) clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id) return clear_result + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while clearing queue: {e}") @@ -312,11 +354,14 @@ async def clear( }, ) async def prune( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> PruneResult: - """Prunes all completed or errored queue items""" + """Prunes all completed or errored queue items. Non-admin users can only prune their own items.""" try: - return ApiDependencies.invoker.services.session_queue.prune(queue_id) + # Admin users can prune all items, non-admin users can only prune their own + user_id = None if current_user.is_admin else current_user.user_id + return ApiDependencies.invoker.services.session_queue.prune(queue_id, user_id=user_id) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while pruning queue: {e}") @@ -423,12 +468,24 @@ async def get_queue_item( operation_id="delete_queue_item", ) async def delete_queue_item( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to delete"), ) -> None: - """Deletes a queue item""" + """Deletes a queue item. Users can only delete their own items unless they are an admin.""" try: + # Get the queue item to check ownership + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + + # Check authorization: user must own the item or be an admin + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="You do not have permission to delete this queue item") + ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id) + except SessionQueueItemNotFoundError: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while deleting queue item: {e}") @@ -441,14 +498,24 @@ async def delete_queue_item( }, ) async def cancel_queue_item( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to cancel"), ) -> SessionQueueItem: - """Deletes a queue item""" + """Cancels a queue item. Users can only cancel their own items unless they are an admin.""" try: + # Get the queue item to check ownership + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + + # Check authorization: user must own the item or be an admin + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="You do not have permission to cancel this queue item") + return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id) except SessionQueueItemNotFoundError: raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling queue item: {e}") @@ -477,13 +544,16 @@ async def counts_by_destination( responses={200: {"model": DeleteByDestinationResult}}, ) async def delete_by_destination( + current_user: CurrentUser, queue_id: str = Path(description="The queue id to query"), destination: str = Path(description="The destination to query"), ) -> DeleteByDestinationResult: - """Deletes all items with the given destination""" + """Deletes all items with the given destination. Non-admin users can only delete their own items.""" try: + # Admin users can delete all items, non-admin users can only delete their own + user_id = None if current_user.is_admin else current_user.user_id return ApiDependencies.invoker.services.session_queue.delete_by_destination( - queue_id=queue_id, destination=destination + queue_id=queue_id, destination=destination, user_id=user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while deleting by destination: {e}") diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index e6c24f14e77..5232dc9c76e 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -58,8 +58,8 @@ def clear(self, queue_id: str) -> ClearResult: pass @abstractmethod - def prune(self, queue_id: str) -> PruneResult: - """Deletes all completed and errored session queue items""" + def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult: + """Deletes all completed and errored session queue items. If user_id is provided, only prunes items owned by that user.""" pass @abstractmethod @@ -110,18 +110,24 @@ def fail_queue_item( pass @abstractmethod - def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult: - """Cancels all queue items with matching batch IDs""" + def cancel_by_batch_ids( + self, queue_id: str, batch_ids: list[str], user_id: Optional[str] = None + ) -> CancelByBatchIDsResult: + """Cancels all queue items with matching batch IDs. If user_id is provided, only cancels items owned by that user.""" pass @abstractmethod - def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult: - """Cancels all queue items with the given batch destination""" + def cancel_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> CancelByDestinationResult: + """Cancels all queue items with the given batch destination. If user_id is provided, only cancels items owned by that user.""" pass @abstractmethod - def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult: - """Deletes all queue items with the given batch destination""" + def delete_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> DeleteByDestinationResult: + """Deletes all queue items with the given batch destination. If user_id is provided, only deletes items owned by that user.""" pass @abstractmethod @@ -130,13 +136,13 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: pass @abstractmethod - def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult: - """Cancels all queue items except in-progress items""" + def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult: + """Cancels all queue items except in-progress items. If user_id is provided, only cancels items owned by that user.""" pass @abstractmethod - def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult: - """Deletes all queue items except in-progress items""" + def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult: + """Deletes all queue items except in-progress items. If user_id is provided, only deletes items owned by that user.""" pass @abstractmethod diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 5c6f529bbba..aa5ce689b40 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -314,9 +314,11 @@ def clear(self, queue_id: str) -> ClearResult: self.__invoker.services.events.emit_queue_cleared(queue_id) return ClearResult(deleted=count) - def prune(self, queue_id: str) -> PruneResult: + def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult: with self._db.transaction() as cursor: - where = """--sql + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id = ? AND ( @@ -324,14 +326,19 @@ def prune(self, queue_id: str) -> PruneResult: OR status = 'failed' OR status = 'canceled' ) + {user_filter} """ + params = [queue_id] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -340,7 +347,7 @@ def prune(self, queue_id: str) -> PruneResult: FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) return PruneResult(deleted=count) @@ -384,10 +391,15 @@ def fail_queue_item( ) return queue_item - def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult: + def cancel_by_batch_ids( + self, queue_id: str, batch_ids: list[str], user_id: Optional[str] = None + ) -> CancelByBatchIDsResult: with self._db.transaction() as cursor: current_queue_item = self.get_current(queue_id) placeholders = ", ".join(["?" for _ in batch_ids]) + + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" where = f"""--sql WHERE queue_id == ? @@ -397,8 +409,12 @@ def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBa AND status != 'failed' -- We will cancel the current item separately below - skip it here AND status != 'in_progress' + {user_filter} """ params = [queue_id] + batch_ids + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) @@ -417,15 +433,22 @@ def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBa tuple(params), ) + # Handle current item separately - check ownership if user_id is provided if current_queue_item is not None and current_queue_item.batch_id in batch_ids: - self._set_queue_item_status(current_queue_item.item_id, "canceled") + if user_id is None or current_queue_item.user_id == user_id: + self._set_queue_item_status(current_queue_item.item_id, "canceled") return CancelByBatchIDsResult(canceled=count) - def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult: + def cancel_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> CancelByDestinationResult: with self._db.transaction() as cursor: current_queue_item = self.get_current(queue_id) - where = """--sql + + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id == ? AND destination == ? @@ -434,15 +457,19 @@ def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDest AND status != 'failed' -- We will cancel the current item separately below - skip it here AND status != 'in_progress' + {user_filter} """ - params = (queue_id, destination) + params = [queue_id, destination] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - params, + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -451,55 +478,78 @@ def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDest SET status = 'canceled' {where}; """, - params, + tuple(params), ) + + # Handle current item separately - check ownership if user_id is provided if current_queue_item is not None and current_queue_item.destination == destination: - self._set_queue_item_status(current_queue_item.item_id, "canceled") + if user_id is None or current_queue_item.user_id == user_id: + self._set_queue_item_status(current_queue_item.item_id, "canceled") + return CancelByDestinationResult(canceled=count) - def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult: + def delete_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> DeleteByDestinationResult: with self._db.transaction() as cursor: current_queue_item = self.get_current(queue_id) + + # Handle current item separately - check ownership if user_id is provided if current_queue_item is not None and current_queue_item.destination == destination: - self.cancel_queue_item(current_queue_item.item_id) - params = (queue_id, destination) + if user_id is None or current_queue_item.user_id == user_id: + self.cancel_queue_item(current_queue_item.item_id) + + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + params = [queue_id, destination] + if user_id is not None: + params.append(user_id) + cursor.execute( - """--sql + f"""--sql SELECT COUNT(*) FROM session_queue WHERE - queue_id = ? - AND destination = ?; + queue_id == ? + AND destination == ? + {user_filter} """, - params, + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( - """--sql - DELETE - FROM session_queue + f"""--sql + DELETE FROM session_queue WHERE - queue_id = ? - AND destination = ?; + queue_id == ? + AND destination == ? + {user_filter} """, - params, + tuple(params), ) return DeleteByDestinationResult(deleted=count) - def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult: + def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult: with self._db.transaction() as cursor: - where = """--sql + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id == ? AND status == 'pending' + {user_filter} """ + params = [queue_id] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -508,7 +558,7 @@ def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResu FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) return DeleteAllExceptCurrentResult(deleted=count) @@ -547,20 +597,27 @@ def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: self._set_queue_item_status(current_queue_item.item_id, "canceled") return CancelByQueueIDResult(canceled=count) - def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult: + def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult: with self._db.transaction() as cursor: - where = """--sql + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id == ? AND status == 'pending' + {user_filter} """ + params = [queue_id] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -569,7 +626,7 @@ def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResu SET status = 'canceled' {where}; """, - (queue_id,), + tuple(params), ) return CancelAllExceptCurrentResult(canceled=count) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index f6ffc406fdd..4f1f4831820 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -304,6 +304,7 @@ "cancelTooltip": "Cancel Current Item", "cancelSucceeded": "Item Canceled", "cancelFailed": "Problem Canceling Item", + "cancelFailedAccessDenied": "Problem Canceling Item: Access Denied", "retrySucceeded": "Item Retried", "retryFailed": "Problem Retrying Item", "confirm": "Confirm", @@ -315,6 +316,7 @@ "clearTooltip": "Cancel and Clear All Items", "clearSucceeded": "Queue Cleared", "clearFailed": "Problem Clearing Queue", + "clearFailedAccessDenied": "Problem Clearing Queue: Access Denied", "cancelBatch": "Cancel Batch", "cancelItem": "Cancel Item", "retryItem": "Retry Item", @@ -345,7 +347,7 @@ "other": "Other", "gallery": "Gallery", "batchFieldValues": "Batch Field Values", - "fieldValuesHidden": "Hidden for privacy", + "fieldValuesHidden": "", "cannotViewDetails": "You do not have permission to view the details of this queue item", "item": "Item", "session": "Session", diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx index d2b2deed993..15ededc99c5 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx @@ -34,6 +34,19 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { const [isOpen, setIsOpen] = useState(false); const currentUser = useAppSelector(selectCurrentUser); + // Check if current user can manage this queue item + const canManageItem = useMemo(() => { + if (!currentUser) { + return false; + } + // Admin users can manage all items + if (currentUser.is_admin) { + return true; + } + // Non-admin users can only manage their own items + return item.user_id === currentUser.user_id; + }, [currentUser, item.user_id]); + // Check if the current user can view this queue item's details const canViewDetails = useMemo(() => { // Admins can view all items @@ -173,7 +186,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { {!isFailed && ( } @@ -182,6 +195,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { {isFailed && ( } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts index 8e6c79b96a0..d36ed0fc589 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelAllExceptCurrentQueueItem.ts @@ -25,10 +25,12 @@ export const useCancelAllExceptCurrentQueueItem = () => { title: t('queue.cancelSucceeded'), status: 'success', }); - } catch { + } catch (error) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts index 98213288710..797a940507b 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts @@ -1,11 +1,30 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { useCurrentQueueItemId } from 'features/queue/hooks/useCurrentQueueItemId'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useGetCurrentQueueItemQuery } from 'services/api/endpoints/queue'; import { useCancelQueueItem } from './useCancelQueueItem'; export const useCancelCurrentQueueItem = () => { const currentQueueItemId = useCurrentQueueItemId(); + const { data: currentQueueItem } = useGetCurrentQueueItemQuery(); + const currentUser = useAppSelector(selectCurrentUser); const cancelQueueItem = useCancelQueueItem(); + + // Check if current user can cancel the current item + const canCancelCurrentItem = useMemo(() => { + if (!currentUser || !currentQueueItem) { + return false; + } + // Admin users can cancel all items + if (currentUser.is_admin) { + return true; + } + // Non-admin users can only cancel their own items + return currentQueueItem.user_id === currentUser.user_id; + }, [currentUser, currentQueueItem]); + const trigger = useCallback( (options?: { withToast?: boolean }) => { if (currentQueueItemId === null) { @@ -19,6 +38,6 @@ export const useCancelCurrentQueueItem = () => { return { trigger, isLoading: cancelQueueItem.isLoading, - isDisabled: cancelQueueItem.isDisabled || currentQueueItemId === null, + isDisabled: cancelQueueItem.isDisabled || currentQueueItemId === null || !canCancelCurrentItem, }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts index c122241cbd1..b85fe8d3734 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts @@ -25,11 +25,13 @@ export const useCancelQueueItem = () => { status: 'success', }); } - } catch { + } catch (error) { if (withToast) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts index 14864e0e3f5..df0eabcb527 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts @@ -26,11 +26,13 @@ export const useCancelQueueItemsByDestination = () => { status: 'success', }); } - } catch { + } catch (error) { if (withToast) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts index a81f7254be3..bd6ea2cc02d 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts @@ -25,10 +25,12 @@ export const useClearQueue = () => { title: t('queue.clearSucceeded'), status: 'success', }); - } catch { + } catch (error) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CLEAR_FAILED', - title: t('queue.clearFailed'), + title: isAccessDenied ? t('queue.clearFailedAccessDenied') : t('queue.clearFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts index 1f34a76d24d..b96c3914703 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts @@ -25,10 +25,12 @@ export const useDeleteAllExceptCurrentQueueItem = () => { title: t('queue.cancelSucceeded'), status: 'success', }); - } catch { + } catch (error) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts index af91196ddfe..699a81ac740 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts @@ -25,11 +25,13 @@ export const useDeleteQueueItem = () => { status: 'success', }); } - } catch { + } catch (error) { if (withToast) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts b/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts index 9e82576a4f4..bc0a95d7bb2 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery, usePauseProcessorMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,10 +11,14 @@ export const usePauseProcessor = () => { const { t } = useTranslation(); const isConnected = useStore($isConnected); const { data: queueStatus } = useGetQueueStatusQuery(); + const currentUser = useAppSelector(selectCurrentUser); const [_trigger, { isLoading }] = usePauseProcessorMutation({ fixedCacheKey: 'pauseProcessor', }); + // Only admin users can pause the processor + const isAdmin = useMemo(() => currentUser?.is_admin ?? false, [currentUser]); + const trigger = useCallback(async () => { try { await _trigger().unwrap(); @@ -30,5 +36,5 @@ export const usePauseProcessor = () => { } }, [_trigger, t]); - return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.processor.is_started }; + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.processor.is_started || !isAdmin }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts b/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts index 901bac39f83..10961abde0c 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useGetQueueStatusQuery, useResumeProcessorMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,10 +11,14 @@ export const useResumeProcessor = () => { const isConnected = useStore($isConnected); const { data: queueStatus } = useGetQueueStatusQuery(); const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); const [_trigger, { isLoading }] = useResumeProcessorMutation({ fixedCacheKey: 'resumeProcessor', }); + // Only admin users can resume the processor + const isAdmin = useMemo(() => currentUser?.is_admin ?? false, [currentUser]); + const trigger = useCallback(async () => { try { await _trigger().unwrap(); @@ -30,5 +36,5 @@ export const useResumeProcessor = () => { } }, [_trigger, t]); - return { trigger, isLoading, isDisabled: !isConnected || queueStatus?.processor.is_started }; + return { trigger, isLoading, isDisabled: !isConnected || queueStatus?.processor.is_started || !isAdmin }; }; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 6562358551e..24323c92dc5 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1444,7 +1444,7 @@ export type paths = { get?: never; /** * Resume - * @description Resumes session processor + * @description Resumes session processor. Admin only. */ put: operations["resume"]; post?: never; @@ -1464,7 +1464,7 @@ export type paths = { get?: never; /** * Pause - * @description Pauses session processor + * @description Pauses session processor. Admin only. */ put: operations["pause"]; post?: never; @@ -1484,7 +1484,7 @@ export type paths = { get?: never; /** * Cancel All Except Current - * @description Immediately cancels all queue items except in-processing items + * @description Immediately cancels all queue items except in-processing items. Non-admin users can only cancel their own items. */ put: operations["cancel_all_except_current"]; post?: never; @@ -1504,7 +1504,7 @@ export type paths = { get?: never; /** * Delete All Except Current - * @description Immediately deletes all queue items except in-processing items + * @description Immediately deletes all queue items except in-processing items. Non-admin users can only delete their own items. */ put: operations["delete_all_except_current"]; post?: never; @@ -1524,7 +1524,7 @@ export type paths = { get?: never; /** * Cancel By Batch Ids - * @description Immediately cancels all queue items from the given batch ids + * @description Immediately cancels all queue items from the given batch ids. Non-admin users can only cancel their own items. */ put: operations["cancel_by_batch_ids"]; post?: never; @@ -1544,7 +1544,7 @@ export type paths = { get?: never; /** * Cancel By Destination - * @description Immediately cancels all queue items with the given origin + * @description Immediately cancels all queue items with the given destination. Non-admin users can only cancel their own items. */ put: operations["cancel_by_destination"]; post?: never; @@ -1564,7 +1564,7 @@ export type paths = { get?: never; /** * Retry Items By Id - * @description Immediately cancels all queue items with the given origin + * @description Retries the given queue items. Users can only retry their own items unless they are an admin. */ put: operations["retry_items_by_id"]; post?: never; @@ -1584,7 +1584,7 @@ export type paths = { get?: never; /** * Clear - * @description Clears the queue entirely, immediately canceling the currently-executing session + * @description Clears the queue entirely. If there's a currently-executing item, users can only cancel it if they own it or are an admin. */ put: operations["clear"]; post?: never; @@ -1604,7 +1604,7 @@ export type paths = { get?: never; /** * Prune - * @description Prunes all completed or errored queue items + * @description Prunes all completed or errored queue items. Non-admin users can only prune their own items. */ put: operations["prune"]; post?: never; @@ -1710,7 +1710,7 @@ export type paths = { post?: never; /** * Delete Queue Item - * @description Deletes a queue item + * @description Deletes a queue item. Users can only delete their own items unless they are an admin. */ delete: operations["delete_queue_item"]; options?: never; @@ -1728,7 +1728,7 @@ export type paths = { get?: never; /** * Cancel Queue Item - * @description Deletes a queue item + * @description Cancels a queue item. Users can only cancel their own items unless they are an admin. */ put: operations["cancel_queue_item"]; post?: never; @@ -1770,7 +1770,7 @@ export type paths = { post?: never; /** * Delete By Destination - * @description Deletes all items with the given destination + * @description Deletes all items with the given destination. Non-admin users can only delete their own items. */ delete: operations["delete_by_destination"]; options?: never;