From 4b8a0b7dd4821efd8fdbd1e88881a0f5d0f7013c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 05:06:07 +0000 Subject: [PATCH] Backend: Add admin board filtering and uncategorized board isolation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/routers/boards.py | 6 +- invokeai/app/api/routers/images.py | 18 ++- .../board_records/board_records_base.py | 6 +- .../board_records/board_records_common.py | 5 + .../board_records/board_records_sqlite.py | 110 ++++++++++++++---- invokeai/app/services/boards/boards_base.py | 6 +- invokeai/app/services/boards/boards_common.py | 9 +- .../app/services/boards/boards_default.py | 28 ++++- .../image_records/image_records_base.py | 7 +- .../image_records/image_records_sqlite.py | 25 +++- invokeai/app/services/images/images_base.py | 5 + .../app/services/images/images_default.py | 10 ++ .../app/services/shared/invocation_context.py | 1 + .../Boards/BoardsList/GalleryBoard.tsx | 11 +- .../features/gallery/store/gallerySlice.ts | 9 ++ .../web/src/services/api/endpoints/auth.ts | 4 + .../frontend/web/src/services/api/schema.ts | 14 ++- .../app/services/auth/test_data_isolation.py | 4 + .../bulk_download/test_bulk_download.py | 14 ++- 19 files changed, 245 insertions(+), 47 deletions(-) diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 786dce0f135..a451d315f44 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -133,14 +133,14 @@ async def list_boards( limit: Optional[int] = Query(default=None, description="The number of boards per page"), include_archived: bool = Query(default=False, description="Whether or not to include archived boards in list"), ) -> Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]]: - """Gets a list of boards for the current user, including shared boards""" + """Gets a list of boards for the current user, including shared boards. Admin users see all boards.""" if all: return ApiDependencies.invoker.services.boards.get_all( - current_user.user_id, order_by, direction, include_archived + current_user.user_id, current_user.is_admin, order_by, direction, include_archived ) elif offset is not None and limit is not None: return ApiDependencies.invoker.services.boards.get_many( - current_user.user_id, order_by, direction, offset, limit, include_archived + current_user.user_id, current_user.is_admin, order_by, direction, offset, limit, include_archived ) else: raise HTTPException( diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index ca144f33fc5..fb876b658b0 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -135,6 +135,7 @@ async def upload_image( workflow=extracted_metadata.invokeai_workflow, graph=extracted_metadata.invokeai_graph, is_intermediate=is_intermediate, + user_id=current_user.user_id, ) response.status_code = 201 @@ -375,6 +376,7 @@ async def get_image_urls( response_model=OffsetPaginatedResults[ImageDTO], ) async def list_image_dtos( + current_user: CurrentUser, image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."), @@ -388,10 +390,19 @@ async def list_image_dtos( starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> OffsetPaginatedResults[ImageDTO]: - """Gets a list of image DTOs""" + """Gets a list of image DTOs for the current user""" image_dtos = ApiDependencies.invoker.services.images.get_many( - offset, limit, starred_first, order_dir, image_origin, categories, is_intermediate, board_id, search_term + offset, + limit, + starred_first, + order_dir, + image_origin, + categories, + is_intermediate, + board_id, + search_term, + current_user.user_id, ) return image_dtos @@ -569,6 +580,7 @@ async def get_bulk_download_item( @images_router.get("/names", operation_id="get_image_names") async def get_image_names( + current_user: CurrentUser, image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."), categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."), is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."), @@ -591,6 +603,8 @@ async def get_image_names( is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, + user_id=current_user.user_id, + is_admin=current_user.is_admin, ) return result except Exception: diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py index 45902352f23..20981f2c7d7 100644 --- a/invokeai/app/services/board_records/board_records_base.py +++ b/invokeai/app/services/board_records/board_records_base.py @@ -43,22 +43,24 @@ def update( def get_many( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, limit: int = 10, include_archived: bool = False, ) -> OffsetPaginatedResults[BoardRecord]: - """Gets many board records for a specific user, including shared boards.""" + """Gets many board records for a specific user, including shared boards. Admin users see all boards.""" pass @abstractmethod def get_all( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False, ) -> list[BoardRecord]: - """Gets all board records for a specific user, including shared boards.""" + """Gets all board records for a specific user, including shared boards. Admin users see all boards.""" pass diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index 5067d42999b..ab6355a3930 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -16,6 +16,8 @@ class BoardRecord(BaseModelExcludeNull): """The unique ID of the board.""" board_name: str = Field(description="The name of the board.") """The name of the board.""" + user_id: str = Field(description="The user ID of the board owner.") + """The user ID of the board owner.""" created_at: Union[datetime, str] = Field(description="The created timestamp of the board.") """The created timestamp of the image.""" updated_at: Union[datetime, str] = Field(description="The updated timestamp of the board.") @@ -35,6 +37,8 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: board_id = board_dict.get("board_id", "unknown") board_name = board_dict.get("board_name", "unknown") + # Default to 'system' for backwards compatibility with boards created before multiuser support + user_id = board_dict.get("user_id", "system") cover_image_name = board_dict.get("cover_image_name", "unknown") created_at = board_dict.get("created_at", get_iso_timestamp()) updated_at = board_dict.get("updated_at", get_iso_timestamp()) @@ -44,6 +48,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: return BoardRecord( board_id=board_id, board_name=board_name, + user_id=user_id, cover_image_name=cover_image_name, created_at=created_at, updated_at=updated_at, diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index 27197e72731..a54f65686fd 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -123,6 +123,7 @@ def update( def get_many( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, @@ -130,8 +131,27 @@ def get_many( include_archived: bool = False, ) -> OffsetPaginatedResults[BoardRecord]: with self._db.transaction() as cursor: - # Build base query - include boards owned by user, shared with user, or public - base_query = """ + # Build base query - admins see all boards, regular users see owned, shared, or public boards + if is_admin: + base_query = """ + SELECT DISTINCT boards.* + FROM boards + {archived_filter} + ORDER BY {order_by} {direction} + LIMIT ? OFFSET ?; + """ + + # Determine archived filter condition + archived_filter = "WHERE 1=1" if include_archived else "WHERE boards.archived = 0" + + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) + + # Execute query to fetch boards + cursor.execute(final_query, (limit, offset)) + else: + base_query = """ SELECT DISTINCT boards.* FROM boards LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id @@ -141,29 +161,43 @@ def get_many( LIMIT ? OFFSET ?; """ - # Determine archived filter condition - archived_filter = "" if include_archived else "AND boards.archived = 0" + # Determine archived filter condition + archived_filter = "" if include_archived else "AND boards.archived = 0" - final_query = base_query.format( - archived_filter=archived_filter, order_by=order_by.value, direction=direction.value - ) + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) - # Execute query to fetch boards - cursor.execute(final_query, (user_id, user_id, limit, offset)) + # Execute query to fetch boards + cursor.execute(final_query, (user_id, user_id, limit, offset)) result = cast(list[sqlite3.Row], cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] - # Determine count query - count boards accessible to user - if include_archived: - count_query = """ + # Determine count query - admins count all boards, regular users count accessible boards + if is_admin: + if include_archived: + count_query = """ + SELECT COUNT(DISTINCT boards.board_id) + FROM boards; + """ + else: + count_query = """ + SELECT COUNT(DISTINCT boards.board_id) + FROM boards + WHERE boards.archived = 0; + """ + cursor.execute(count_query) + else: + if include_archived: + count_query = """ SELECT COUNT(DISTINCT boards.board_id) FROM boards LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1); """ - else: - count_query = """ + else: + count_query = """ SELECT COUNT(DISTINCT boards.board_id) FROM boards LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id @@ -171,8 +205,8 @@ def get_many( AND boards.archived = 0; """ - # Execute count query - cursor.execute(count_query, (user_id, user_id)) + # Execute count query + cursor.execute(count_query, (user_id, user_id)) count = cast(int, cursor.fetchone()[0]) @@ -181,13 +215,39 @@ def get_many( def get_all( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False, ) -> list[BoardRecord]: with self._db.transaction() as cursor: - if order_by == BoardRecordOrderBy.Name: - base_query = """ + # Build query - admins see all boards, regular users see owned, shared, or public boards + if is_admin: + if order_by == BoardRecordOrderBy.Name: + base_query = """ + SELECT DISTINCT boards.* + FROM boards + {archived_filter} + ORDER BY LOWER(boards.board_name) {direction} + """ + else: + base_query = """ + SELECT DISTINCT boards.* + FROM boards + {archived_filter} + ORDER BY {order_by} {direction} + """ + + archived_filter = "WHERE 1=1" if include_archived else "WHERE boards.archived = 0" + + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) + + cursor.execute(final_query) + else: + if order_by == BoardRecordOrderBy.Name: + base_query = """ SELECT DISTINCT boards.* FROM boards LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id @@ -195,8 +255,8 @@ def get_all( {archived_filter} ORDER BY LOWER(boards.board_name) {direction} """ - else: - base_query = """ + else: + base_query = """ SELECT DISTINCT boards.* FROM boards LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id @@ -205,13 +265,13 @@ def get_all( ORDER BY {order_by} {direction} """ - archived_filter = "" if include_archived else "AND boards.archived = 0" + archived_filter = "" if include_archived else "AND boards.archived = 0" - final_query = base_query.format( - archived_filter=archived_filter, order_by=order_by.value, direction=direction.value - ) + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) - cursor.execute(final_query, (user_id, user_id)) + cursor.execute(final_query, (user_id, user_id)) result = cast(list[sqlite3.Row], cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] diff --git a/invokeai/app/services/boards/boards_base.py b/invokeai/app/services/boards/boards_base.py index 2affda2bcea..914dfa3d0d7 100644 --- a/invokeai/app/services/boards/boards_base.py +++ b/invokeai/app/services/boards/boards_base.py @@ -47,22 +47,24 @@ def delete( def get_many( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, limit: int = 10, include_archived: bool = False, ) -> OffsetPaginatedResults[BoardDTO]: - """Gets many boards for a specific user, including shared boards.""" + """Gets many boards for a specific user, including shared boards. Admin users see all boards.""" pass @abstractmethod def get_all( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False, ) -> list[BoardDTO]: - """Gets all boards for a specific user, including shared boards.""" + """Gets all boards for a specific user, including shared boards. Admin users see all boards.""" pass diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py index 68cd3603287..99952fec134 100644 --- a/invokeai/app/services/boards/boards_common.py +++ b/invokeai/app/services/boards/boards_common.py @@ -14,10 +14,16 @@ class BoardDTO(BoardRecord): """The number of images in the board.""" asset_count: int = Field(description="The number of assets in the board.") """The number of assets in the board.""" + owner_username: Optional[str] = Field(default=None, description="The username of the board owner (for admin view).") + """The username of the board owner (for admin view).""" def board_record_to_dto( - board_record: BoardRecord, cover_image_name: Optional[str], image_count: int, asset_count: int + board_record: BoardRecord, + cover_image_name: Optional[str], + image_count: int, + asset_count: int, + owner_username: Optional[str] = None, ) -> BoardDTO: """Converts a board record to a board DTO.""" return BoardDTO( @@ -25,4 +31,5 @@ def board_record_to_dto( cover_image_name=cover_image_name, image_count=image_count, asset_count=asset_count, + owner_username=owner_username, ) diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index c7d80231ed0..71465815ef9 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -53,6 +53,7 @@ def delete(self, board_id: str) -> None: def get_many( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, @@ -60,7 +61,7 @@ def get_many( include_archived: bool = False, ) -> OffsetPaginatedResults[BoardDTO]: board_records = self.__invoker.services.board_records.get_many( - user_id, order_by, direction, offset, limit, include_archived + user_id, is_admin, order_by, direction, offset, limit, include_archived ) board_dtos = [] for r in board_records.items: @@ -72,18 +73,29 @@ def get_many( image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id) asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count)) + + # For admin users, include owner username + owner_username = None + if is_admin: + owner = self.__invoker.services.users.get(r.user_id) + if owner: + owner_username = owner.display_name or owner.email + + board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, owner_username)) return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) def get_all( self, user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False, ) -> list[BoardDTO]: - board_records = self.__invoker.services.board_records.get_all(user_id, order_by, direction, include_archived) + board_records = self.__invoker.services.board_records.get_all( + user_id, is_admin, order_by, direction, include_archived + ) board_dtos = [] for r in board_records: cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id) @@ -94,6 +106,14 @@ def get_all( image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id) asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count)) + + # For admin users, include owner username + owner_username = None + if is_admin: + owner = self.__invoker.services.users.get(r.user_id) + if owner: + owner_username = owner.display_name or owner.email + + board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, owner_username)) return board_dtos diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index ff271e2394e..16405c52708 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -50,8 +50,10 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageRecord]: - """Gets a page of image records.""" + """Gets a page of image records. When board_id is 'none', filters by user_id for per-user uncategorized images unless is_admin is True.""" pass # TODO: The database has a nullable `deleted_at` column, currently unused. @@ -90,6 +92,7 @@ def save( session_id: Optional[str] = None, node_id: Optional[str] = None, metadata: Optional[str] = None, + user_id: Optional[str] = None, ) -> datetime: """Saves an image record.""" pass @@ -109,6 +112,8 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: """Gets ordered list of image names with metadata for optimistic updates.""" pass diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index cb968e76bb8..c6c237fc1e7 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -134,6 +134,8 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageRecord]: with self._db.transaction() as cursor: # Manually build two queries - one for the count, one for the records @@ -186,6 +188,13 @@ def get_many( query_conditions += """--sql AND board_images.board_id IS NULL """ + # For uncategorized images, filter by user_id to ensure per-user isolation + # Admin users can see all uncategorized images from all users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) elif board_id is not None: query_conditions += """--sql AND board_images.board_id = ? @@ -305,6 +314,7 @@ def save( session_id: Optional[str] = None, node_id: Optional[str] = None, metadata: Optional[str] = None, + user_id: Optional[str] = None, ) -> datetime: with self._db.transaction() as cursor: try: @@ -321,9 +331,10 @@ def save( metadata, is_intermediate, starred, - has_workflow + has_workflow, + user_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """, ( image_name, @@ -337,6 +348,7 @@ def save( is_intermediate, starred, has_workflow, + user_id or "system", ), ) @@ -386,6 +398,8 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: with self._db.transaction() as cursor: # Build query conditions (reused for both starred count and image names queries) @@ -417,6 +431,13 @@ def get_image_names( query_conditions += """--sql AND board_images.board_id IS NULL """ + # For uncategorized images, filter by user_id to ensure per-user isolation + # Admin users can see all uncategorized images from all users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) elif board_id is not None: query_conditions += """--sql AND board_images.board_id = ? diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index e1fe02c1ec5..d11d75b3c1d 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -55,6 +55,7 @@ def create( metadata: Optional[str] = None, workflow: Optional[str] = None, graph: Optional[str] = None, + user_id: Optional[str] = None, ) -> ImageDTO: """Creates an image, storing the file and its metadata.""" pass @@ -125,6 +126,8 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageDTO]: """Gets a paginated list of image DTOs with starred images first when starred_first=True.""" pass @@ -159,6 +162,8 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: """Gets ordered list of image names with metadata for optimistic updates.""" pass diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 64ef0751b24..e82bd7f4de1 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -45,6 +45,7 @@ def create( metadata: Optional[str] = None, workflow: Optional[str] = None, graph: Optional[str] = None, + user_id: Optional[str] = None, ) -> ImageDTO: if image_origin not in ResourceOrigin: raise InvalidOriginException @@ -72,6 +73,7 @@ def create( node_id=node_id, metadata=metadata, session_id=session_id, + user_id=user_id, ) if board_id is not None: try: @@ -215,6 +217,8 @@ def get_many( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageDTO]: try: results = self.__invoker.services.image_records.get_many( @@ -227,6 +231,8 @@ def get_many( is_intermediate, board_id, search_term, + user_id, + is_admin, ) image_dtos = [ @@ -320,6 +326,8 @@ def get_image_names( is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: try: return self.__invoker.services.image_records.get_image_names( @@ -330,6 +338,8 @@ def get_image_names( is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, + user_id=user_id, + is_admin=is_admin, ) except Exception as e: self.__invoker.services.logger.error("Problem getting image names") diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py index 4add364c450..33a9557cf7b 100644 --- a/invokeai/app/services/shared/invocation_context.py +++ b/invokeai/app/services/shared/invocation_context.py @@ -230,6 +230,7 @@ def save( graph=graph_, session_id=self._data.queue_item.session_id, node_id=self._data.invocation.id, + user_id=self._data.queue_item.user_id, ) def get_pil(self, image_name: str, mode: IMAGE_MODES | None = None) -> Image: diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 1ddc4b0db36..4d821f819c6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,6 +2,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import type { AddImageToBoardDndTargetData } from 'features/dnd/dnd'; import { addImageToBoardDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -36,6 +37,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); const selectedBoardId = useAppSelector(selectSelectedBoardId); + const currentUser = useAppSelector(selectCurrentUser); const onClick = useCallback(() => { if (selectedBoardId !== board.board_id) { dispatch(boardIdSelected({ boardId: board.board_id })); @@ -58,6 +60,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { [board] ); + const showOwner = currentUser?.is_admin && board.owner_username; + return ( @@ -85,8 +89,13 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { h="full" > - + + {showOwner && ( + + {board.owner_username} + + )} {autoAddBoardId === board.board_id && } {board.archived && } diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index d66feefa2c9..9d4d2bfd75d 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject, uniq } from 'es-toolkit'; +import { logout } from 'features/auth/store/authSlice'; import type { BoardRecordOrderBy } from 'services/api/types'; import { assert } from 'tsafe'; @@ -142,6 +143,14 @@ const slice = createSlice({ state.boardsListOrderDir = action.payload; }, }, + extraReducers(builder) { + // Clear board-related state on logout to prevent stale data when switching users + builder.addCase(logout, (state) => { + state.selectedBoardId = 'none'; + state.autoAddBoardId = 'none'; + state.boardSearchText = ''; + }); + }, }); export const { diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts index 9373bc8982f..35f99095cb5 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts @@ -42,12 +42,16 @@ export const authApi = api.injectEndpoints({ method: 'POST', body: credentials, }), + // Invalidate boards and images cache on successful login to refresh data for new user + invalidatesTags: ['Board', 'Image', 'ImageList', 'ImageNameList', 'ImageCollection', 'ImageMetadata'], }), logout: build.mutation({ query: () => ({ url: 'api/v1/auth/logout', method: 'POST', }), + // Invalidate boards and images cache on logout to clear stale data + invalidatesTags: ['Board', 'Image', 'ImageList', 'ImageNameList', 'ImageCollection', 'ImageMetadata'], }), getCurrentUser: build.query({ query: () => 'api/v1/auth/me', diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 348fbde6e05..587d1733c2b 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -666,7 +666,7 @@ export type paths = { }; /** * List Image Dtos - * @description Gets a list of image DTOs + * @description Gets a list of image DTOs for the current user */ get: operations["list_image_dtos"]; put?: never; @@ -991,7 +991,7 @@ export type paths = { }; /** * List Boards - * @description Gets a list of boards for the current user, including shared boards + * @description Gets a list of boards for the current user, including shared boards. Admin users see all boards. */ get: operations["list_boards"]; put?: never; @@ -2653,6 +2653,11 @@ export type components = { * @description The name of the board. */ board_name: string; + /** + * User Id + * @description The user ID of the board owner. + */ + user_id: string; /** * Created At * @description The created timestamp of the board. @@ -2688,6 +2693,11 @@ export type components = { * @description The number of assets in the board. */ asset_count: number; + /** + * Owner Username + * @description The username of the board owner (for admin view). + */ + owner_username?: string | null; }; /** * BoardField diff --git a/tests/app/services/auth/test_data_isolation.py b/tests/app/services/auth/test_data_isolation.py index 0cf5b27eaf0..e6b6dab3e97 100644 --- a/tests/app/services/auth/test_data_isolation.py +++ b/tests/app/services/auth/test_data_isolation.py @@ -92,6 +92,7 @@ def test_user_can_only_see_own_boards(self, monkeypatch: Any, mock_invoker: Invo # User1 should only see their board user1_boards = board_service.get_many( user_id=user1_id, + is_admin=False, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Ascending, ) @@ -103,6 +104,7 @@ def test_user_can_only_see_own_boards(self, monkeypatch: Any, mock_invoker: Invo # User2 should only see their board user2_boards = board_service.get_many( user_id=user2_id, + is_admin=False, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Ascending, ) @@ -377,11 +379,13 @@ def test_concurrent_user_operations_maintain_isolation(self, mock_invoker: Invok # Verify isolation is maintained user1_boards = board_service.get_many( user_id=user1.user_id, + is_admin=False, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Ascending, ) user2_boards = board_service.get_many( user_id=user2.user_id, + is_admin=False, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Ascending, ) diff --git a/tests/app/services/bulk_download/test_bulk_download.py b/tests/app/services/bulk_download/test_bulk_download.py index 223ecc88632..b568c108ef7 100644 --- a/tests/app/services/bulk_download/test_bulk_download.py +++ b/tests/app/services/bulk_download/test_bulk_download.py @@ -127,7 +127,12 @@ def test_generate_id_with_board_id(monkeypatch: Any, mock_invoker: Invoker): def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + user_id="test_user", + created_at="None", + updated_at="None", + archived=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get) @@ -156,7 +161,12 @@ def test_handler_board_id(tmp_path: Path, monkeypatch: Any, mock_image_dto: Imag def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + user_id="test_user", + created_at="None", + updated_at="None", + archived=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get)