From 13faa0f69c125a99721cffe06eb9b8b82e79336b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 02:51:42 +0000 Subject: [PATCH 1/3] Add board visibility (private/shared/public) feature with tests and UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/api/routers/boards.py | 17 +- .../board_records/board_records_common.py | 22 ++ .../board_records/board_records_sqlite.py | 22 +- .../app/services/shared/sqlite/sqlite_util.py | 2 + .../migrations/migration_29.py | 59 +++++ invokeai/frontend/web/public/locales/en.json | 12 +- .../components/Boards/BoardContextMenu.tsx | 77 +++++- .../Boards/BoardsList/GalleryBoard.tsx | 16 +- .../frontend/web/src/services/api/schema.ts | 10 + tests/app/routers/test_boards_multiuser.py | 220 ++++++++++++++++++ 10 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index e93bb8b2a9b..5330951a667 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -6,7 +6,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies -from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy +from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.image_records.image_records_common import ImageCategory from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -56,7 +56,14 @@ async def get_board( except Exception: raise HTTPException(status_code=404, detail="Board not found") - if not current_user.is_admin and result.user_id != current_user.user_id: + # Admins can access any board. + # Owners can access their own boards. + # Shared and public boards are visible to all authenticated users. + if ( + not current_user.is_admin + and result.user_id != current_user.user_id + and result.board_visibility == BoardVisibility.Private + ): raise HTTPException(status_code=403, detail="Not authorized to access this board") return result @@ -188,7 +195,11 @@ async def list_all_board_image_names( except Exception: raise HTTPException(status_code=404, detail="Board not found") - if not current_user.is_admin and board.user_id != current_user.user_id: + if ( + not current_user.is_admin + and board.user_id != current_user.user_id + and board.board_visibility == BoardVisibility.Private + ): raise HTTPException(status_code=403, detail="Not authorized to access this board") image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index ab6355a3930..b263f264cb8 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -9,6 +9,17 @@ from invokeai.app.util.model_exclude_null import BaseModelExcludeNull +class BoardVisibility(str, Enum, metaclass=MetaEnum): + """The visibility options for a board.""" + + Private = "private" + """Only the board owner (and admins) can see and modify this board.""" + Shared = "shared" + """All users can view this board, but only the owner (and admins) can modify it.""" + Public = "public" + """All users can view this board; only the owner (and admins) can modify its structure.""" + + class BoardRecord(BaseModelExcludeNull): """Deserialized board record.""" @@ -28,6 +39,10 @@ class BoardRecord(BaseModelExcludeNull): """The name of the cover image of the board.""" archived: bool = Field(description="Whether or not the board is archived.") """Whether or not the board is archived.""" + board_visibility: BoardVisibility = Field( + default=BoardVisibility.Private, description="The visibility of the board." + ) + """The visibility of the board (private, shared, or public).""" def deserialize_board_record(board_dict: dict) -> BoardRecord: @@ -44,6 +59,11 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: updated_at = board_dict.get("updated_at", get_iso_timestamp()) deleted_at = board_dict.get("deleted_at", get_iso_timestamp()) archived = board_dict.get("archived", False) + board_visibility_raw = board_dict.get("board_visibility", BoardVisibility.Private.value) + try: + board_visibility = BoardVisibility(board_visibility_raw) + except ValueError: + board_visibility = BoardVisibility.Private return BoardRecord( board_id=board_id, @@ -54,6 +74,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: updated_at=updated_at, deleted_at=deleted_at, archived=archived, + board_visibility=board_visibility, ) @@ -61,6 +82,7 @@ class BoardChanges(BaseModel, extra="forbid"): board_name: Optional[str] = Field(default=None, description="The board's new name.", max_length=300) cover_image_name: Optional[str] = Field(default=None, description="The name of the board's new cover image.") archived: Optional[bool] = Field(default=None, description="Whether or not the board is archived") + board_visibility: Optional[BoardVisibility] = Field(default=None, description="The visibility of the board.") class BoardRecordOrderBy(str, Enum, metaclass=MetaEnum): diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index a54f65686fd..f5e36954725 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -9,6 +9,7 @@ BoardRecordNotFoundException, BoardRecordOrderBy, BoardRecordSaveException, + BoardVisibility, deserialize_board_record, ) from invokeai.app.services.shared.pagination import OffsetPaginatedResults @@ -116,6 +117,17 @@ def update( (changes.archived, board_id), ) + # Change the visibility of a board + if changes.board_visibility is not None: + cursor.execute( + """--sql + UPDATE boards + SET board_visibility = ? + WHERE board_id = ?; + """, + (changes.board_visibility.value, board_id), + ) + except sqlite3.Error as e: raise BoardRecordSaveException from e return self.get(board_id) @@ -155,7 +167,7 @@ def get_many( SELECT DISTINCT boards.* 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) + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public')) {archived_filter} ORDER BY {order_by} {direction} LIMIT ? OFFSET ?; @@ -194,14 +206,14 @@ def get_many( 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); + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public')); """ else: 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) + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public')) AND boards.archived = 0; """ @@ -251,7 +263,7 @@ def get_all( SELECT DISTINCT boards.* 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) + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public')) {archived_filter} ORDER BY LOWER(boards.board_name) {direction} """ @@ -260,7 +272,7 @@ def get_all( SELECT DISTINCT boards.* 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) + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.board_visibility IN ('shared', 'public')) {archived_filter} ORDER BY {order_by} {direction} """ diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 2478e8cdcae..fb8ca9fca38 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -31,6 +31,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import build_migration_26 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -79,6 +80,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_26(app_config=config, logger=logger)) migrator.register_migration(build_migration_27()) migrator.register_migration(build_migration_28()) + migrator.register_migration(build_migration_29()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py new file mode 100644 index 00000000000..c3fa48e6377 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py @@ -0,0 +1,59 @@ +"""Migration 29: Add board_visibility column to boards table. + +This migration adds a board_visibility column to the boards table to support +three visibility levels: + - 'private': only the board owner (and admins) can view/modify + - 'shared': all users can view, but only the owner (and admins) can modify + - 'public': all users can view; only the owner (and admins) can modify the + board structure (rename/archive/delete) + +Existing boards with is_public = 1 are migrated to 'public'. +All other existing boards default to 'private'. +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration29Callback: + """Migration to add board_visibility column to the boards table.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._update_boards_table(cursor) + + def _update_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Add board_visibility column to boards table.""" + # Check if boards table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(boards);") + columns = [row[1] for row in cursor.fetchall()] + + if "board_visibility" not in columns: + cursor.execute( + "ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';" + ) + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);" + ) + # Migrate existing is_public = 1 boards to 'public' + if "is_public" in columns: + cursor.execute( + "UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;" + ) + + +def build_migration_29() -> Migration: + """Builds the migration object for migrating from version 28 to version 29. + + This migration adds the board_visibility column to the boards table, + supporting 'private', 'shared', and 'public' visibility levels. + """ + return Migration( + from_version=28, + to_version=29, + callback=Migration29Callback(), + ) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 41dda9f8ee6..76ddeda059a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -154,7 +154,17 @@ "imagesWithCount_other": "{{count}} images", "assetsWithCount_one": "{{count}} asset", "assetsWithCount_other": "{{count}} assets", - "updateBoardError": "Error updating board" + "updateBoardError": "Error updating board", + "setBoardVisibility": "Set Board Visibility", + "setVisibilityPrivate": "Set Private", + "setVisibilityShared": "Set Shared", + "setVisibilityPublic": "Set Public", + "visibilityPrivate": "Private", + "visibilityShared": "Shared", + "visibilityPublic": "Public", + "visibilityBadgeShared": "Shared board", + "visibilityBadgePublic": "Public board", + "updateBoardVisibilityError": "Error updating board visibility" }, "accordions": { "generation": { diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index 5cc25f6c038..9b6ace398ee 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -2,13 +2,23 @@ import type { ContextMenuProps } from '@invoke-ai/ui-library'; import { ContextMenu, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { $boardToDelete } from 'features/gallery/components/Boards/DeleteBoardModal'; import { selectAutoAddBoardId, selectAutoAssignBoardOnClick } from 'features/gallery/store/gallerySelectors'; import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; import { toast } from 'features/toast/toast'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiArchiveBold, PiArchiveFill, PiDownloadBold, PiPlusBold, PiTrashSimpleBold } from 'react-icons/pi'; +import { + PiArchiveBold, + PiArchiveFill, + PiDownloadBold, + PiGlobeBold, + PiLockBold, + PiPlusBold, + PiShareNetworkBold, + PiTrashSimpleBold, +} from 'react-icons/pi'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useBulkDownloadImagesMutation } from 'services/api/endpoints/images'; import { useBoardName } from 'services/api/hooks/useBoardName'; @@ -23,6 +33,7 @@ const BoardContextMenu = ({ board, children }: Props) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); + const currentUser = useAppSelector(selectCurrentUser); const selectIsSelectedForAutoAdd = useMemo( () => createSelector(selectAutoAddBoardId, (autoAddBoardId) => board.board_id === autoAddBoardId), [board.board_id] @@ -35,6 +46,10 @@ const BoardContextMenu = ({ board, children }: Props) => { const [bulkDownload] = useBulkDownloadImagesMutation(); + // Only the board owner or admin can modify visibility + const canChangeVisibility = + currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id); + const handleSetAutoAdd = useCallback(() => { dispatch(autoAddBoardIdChanged(board.board_id)); }, [board.board_id, dispatch]); @@ -64,6 +79,35 @@ const BoardContextMenu = ({ board, children }: Props) => { }); }, [board.board_id, updateBoard]); + const handleSetVisibility = useCallback( + async (visibility: 'private' | 'shared' | 'public') => { + try { + await updateBoard({ + board_id: board.board_id, + changes: { board_visibility: visibility }, + }).unwrap(); + } catch { + toast({ status: 'error', title: t('boards.updateBoardVisibilityError') }); + } + }, + [board.board_id, t, updateBoard] + ); + + const handleSetVisibilityPrivate = useCallback( + () => handleSetVisibility('private'), + [handleSetVisibility] + ); + + const handleSetVisibilityShared = useCallback( + () => handleSetVisibility('shared'), + [handleSetVisibility] + ); + + const handleSetVisibilityPublic = useCallback( + () => handleSetVisibility('public'), + [handleSetVisibility] + ); + const setAsBoardToDelete = useCallback(() => { $boardToDelete.set(board); }, [board]); @@ -94,6 +138,32 @@ const BoardContextMenu = ({ board, children }: Props) => { )} + {canChangeVisibility && ( + <> + } + onClick={handleSetVisibilityPrivate} + isDisabled={board.board_visibility === 'private'} + > + {t('boards.setVisibilityPrivate')} + + } + onClick={handleSetVisibilityShared} + isDisabled={board.board_visibility === 'shared'} + > + {t('boards.setVisibilityShared')} + + } + onClick={handleSetVisibilityPublic} + isDisabled={board.board_visibility === 'public'} + > + {t('boards.setVisibilityPublic')} + + + )} + } onClick={setAsBoardToDelete} isDestructive> {t('boards.deleteBoard')} @@ -108,8 +178,13 @@ const BoardContextMenu = ({ board, children }: Props) => { t, handleBulkDownload, board.archived, + board.board_visibility, handleUnarchive, handleArchive, + canChangeVisibility, + handleSetVisibilityPrivate, + handleSetVisibilityShared, + handleSetVisibilityPublic, setAsBoardToDelete, ] ); 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 4d821f819c6..ee2fd077167 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 @@ -18,7 +18,7 @@ import { import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiArchiveBold, PiImageSquare } from 'react-icons/pi'; +import { PiArchiveBold, PiGlobeBold, PiImageSquare, PiShareNetworkBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import type { BoardDTO } from 'services/api/types'; @@ -99,6 +99,20 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { {autoAddBoardId === board.board_id && } {board.archived && } + {board.board_visibility === 'shared' && ( + + + + + + )} + {board.board_visibility === 'public' && ( + + + + + + )} {board.image_count} | {board.asset_count} diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 31cc6ad6c51..b4d17ea3697 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -3046,6 +3046,8 @@ export type components = { * @description Whether or not the board is archived */ archived?: boolean | null; + /** @description The visibility of the board. */ + board_visibility?: components["schemas"]["BoardVisibility"] | null; }; /** * BoardDTO @@ -3107,6 +3109,8 @@ export type components = { * @description The username of the board owner (for admin view). */ owner_username?: string | null; + /** @description The visibility of the board. */ + board_visibility: components["schemas"]["BoardVisibility"]; }; /** * BoardField @@ -3125,6 +3129,12 @@ export type components = { * @enum {string} */ BoardRecordOrderBy: "created_at" | "board_name"; + /** + * BoardVisibility + * @description The visibility options for a board. + * @enum {string} + */ + BoardVisibility: "private" | "shared" | "public"; /** Body_add_image_to_board */ Body_add_image_to_board: { /** diff --git a/tests/app/routers/test_boards_multiuser.py b/tests/app/routers/test_boards_multiuser.py index d5c48481567..ab297550c9e 100644 --- a/tests/app/routers/test_boards_multiuser.py +++ b/tests/app/routers/test_boards_multiuser.py @@ -457,3 +457,223 @@ def test_enqueue_batch_requires_auth(enable_multiuser_for_tests: Any, client: Te }, ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# --------------------------------------------------------------------------- +# Board visibility tests +# --------------------------------------------------------------------------- + + +def test_board_created_with_private_visibility(client: TestClient, user1_token: str): + """Test that newly created boards default to private visibility.""" + create = client.post( + "/api/v1/boards/?board_name=Visibility+Default+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + data = create.json() + assert data["board_visibility"] == "private" + + +def test_set_board_visibility_shared(client: TestClient, user1_token: str): + """Test that the board owner can set their board to shared.""" + create = client.post( + "/api/v1/boards/?board_name=Shared+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "shared"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["board_visibility"] == "shared" + + +def test_set_board_visibility_public(client: TestClient, user1_token: str): + """Test that the board owner can set their board to public.""" + create = client.post( + "/api/v1/boards/?board_name=Public+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "public"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["board_visibility"] == "public" + + +def test_shared_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str): + """Test that a shared board is accessible to other authenticated users.""" + # user1 creates a board and sets it to shared + create = client.post( + "/api/v1/boards/?board_name=User1+Shared+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "shared"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + # user2 should be able to access the shared board + response = client.get( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["board_id"] == board_id + + +def test_public_board_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str): + """Test that a public board is accessible to other authenticated users.""" + # user1 creates a board and sets it to public + create = client.post( + "/api/v1/boards/?board_name=User1+Public+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "public"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + # user2 should be able to access the public board + response = client.get( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["board_id"] == board_id + + +def test_shared_board_appears_in_other_user_list(client: TestClient, user1_token: str, user2_token: str): + """Test that shared boards appear in other users' board listings.""" + # user1 creates and shares a board + create = client.post( + "/api/v1/boards/?board_name=User1+Listed+Shared+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "shared"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + # user2 should see the shared board in their listing + response = client.get( + "/api/v1/boards/?all=true", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + board_ids = [b["board_id"] for b in response.json()] + assert board_id in board_ids + + +def test_private_board_not_visible_after_privacy_change(client: TestClient, user1_token: str, user2_token: str): + """Test that reverting a board from shared to private hides it from other users.""" + # user1 creates a board, makes it shared, then reverts to private + create = client.post( + "/api/v1/boards/?board_name=Reverted+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "shared"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "private"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + # user2 should not be able to access the now-private board + response = client.get( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_non_owner_cannot_change_board_visibility(client: TestClient, user1_token: str, user2_token: str): + """Test that a non-owner cannot change a board's visibility.""" + # user1 creates a board + create = client.post( + "/api/v1/boards/?board_name=User1+Private+Locked+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + # user2 tries to make it public - should be forbidden + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "public"}, + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_shared_board_image_names_visible_to_other_users( + client: TestClient, user1_token: str, user2_token: str +): + """Test that image names for shared boards are accessible to other users.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Shared+Images+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "shared"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + + # user2 can access image names for a shared board + response = client.get( + f"/api/v1/boards/{board_id}/image_names", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + +def test_admin_can_change_any_board_visibility(client: TestClient, admin_token: str, user1_token: str): + """Test that an admin can change the visibility of any user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+For+Admin+Visibility", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + # Admin sets it to public + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_visibility": "public"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["board_visibility"] == "public" From f38d1abc1a4feefea17d825949e6aab198763647 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:46:10 +0000 Subject: [PATCH 2/3] Enforce read-only access for non-owners of shared/public boards in UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- .../components/Boards/BoardContextMenu.tsx | 12 ++++++- .../Boards/BoardsList/BoardEditableTitle.tsx | 8 +++-- .../Boards/BoardsList/GalleryBoard.tsx | 10 +++++- .../MenuItems/ContextMenuItemChangeBoard.tsx | 6 +++- .../MenuItems/ContextMenuItemDeleteImage.tsx | 8 +++++ .../MultipleSelectionMenuItems.tsx | 13 ++++++-- .../ImageGrid/GalleryItemDeleteIconButton.tsx | 6 +++- .../components/InvokeQueueBackButton.tsx | 6 +++- .../src/services/api/hooks/useAutoAddBoard.ts | 21 ++++++++++++ .../src/services/api/hooks/useBoardAccess.ts | 32 +++++++++++++++++++ .../services/api/hooks/useSelectedBoard.ts | 21 ++++++++++++ 11 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts create mode 100644 invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts create mode 100644 invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index 9b6ace398ee..a4a4ae307a3 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -21,6 +21,7 @@ import { } from 'react-icons/pi'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useBulkDownloadImagesMutation } from 'services/api/endpoints/images'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; import { useBoardName } from 'services/api/hooks/useBoardName'; import type { BoardDTO } from 'services/api/types'; @@ -50,6 +51,8 @@ const BoardContextMenu = ({ board, children }: Props) => { const canChangeVisibility = currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id); + const { canDeleteBoard } = useBoardAccess(board); + const handleSetAutoAdd = useCallback(() => { dispatch(autoAddBoardIdChanged(board.board_id)); }, [board.board_id, dispatch]); @@ -164,7 +167,13 @@ const BoardContextMenu = ({ board, children }: Props) => { )} - } onClick={setAsBoardToDelete} isDestructive> + } + onClick={setAsBoardToDelete} + isDestructive + isDisabled={!canDeleteBoard} + > {t('boards.deleteBoard')} @@ -185,6 +194,7 @@ const BoardContextMenu = ({ board, children }: Props) => { handleSetVisibilityPrivate, handleSetVisibilityShared, handleSetVisibilityPublic, + canDeleteBoard, setAsBoardToDelete, ] ); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx index a78f5706e10..0e4216c3cb2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx @@ -7,6 +7,7 @@ import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPencilBold } from 'react-icons/pi'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; import type { BoardDTO } from 'services/api/types'; type Props = { @@ -19,6 +20,7 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => { const isHovering = useBoolean(false); const inputRef = useRef(null); const [updateBoard, updateBoardResult] = useUpdateBoardMutation(); + const { canRenameBoard } = useBoardAccess(board); const onChange = useCallback( async (board_name: string) => { @@ -51,13 +53,13 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => { fontWeight="semibold" userSelect="none" color={isSelected ? 'base.100' : 'base.300'} - onDoubleClick={editable.startEditing} - cursor="text" + onDoubleClick={canRenameBoard ? editable.startEditing : undefined} + cursor={canRenameBoard ? 'text' : 'default'} noOfLines={1} > {editable.value} - {isHovering.isTrue && ( + {canRenameBoard && isHovering.isTrue && ( } 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 ee2fd077167..10fbe618322 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 @@ -20,6 +20,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArchiveBold, PiGlobeBold, PiImageSquare, PiShareNetworkBold } from 'react-icons/pi'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; import type { BoardDTO } from 'services/api/types'; const _hover: SystemStyleObject = { @@ -62,6 +63,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { const showOwner = currentUser?.is_admin && board.owner_username; + const { canWriteImages } = useBoardAccess(board); + return ( @@ -122,7 +125,12 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { )} - + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx index 71764870153..f5c044132e5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx @@ -5,11 +5,15 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFoldersBold } from 'react-icons/pi'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; +import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard'; export const ContextMenuItemChangeBoard = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const imageDTO = useImageDTOContext(); + const selectedBoard = useSelectedBoard(); + const { canWriteImages } = useBoardAccess(selectedBoard); const onClick = useCallback(() => { dispatch(imagesToChangeSelected([imageDTO.image_name])); @@ -17,7 +21,7 @@ export const ContextMenuItemChangeBoard = memo(() => { }, [dispatch, imageDTO]); return ( - } onClickCapture={onClick}> + } onClickCapture={onClick} isDisabled={!canWriteImages}> {t('boards.changeBoard')} ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx index e20221f3423..5dfa7116b17 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx @@ -4,11 +4,15 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; +import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard'; export const ContextMenuItemDeleteImage = memo(() => { const { t } = useTranslation(); const deleteImageModal = useDeleteImageModalApi(); const imageDTO = useImageDTOContext(); + const selectedBoard = useSelectedBoard(); + const { canWriteImages } = useBoardAccess(selectedBoard); const onClick = useCallback(async () => { try { @@ -18,6 +22,10 @@ export const ContextMenuItemDeleteImage = memo(() => { } }, [deleteImageModal, imageDTO]); + if (!canWriteImages) { + return null; + } + return ( } diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx index d148332943c..ee3c8e4e985 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx @@ -10,12 +10,16 @@ import { useStarImagesMutation, useUnstarImagesMutation, } from 'services/api/endpoints/images'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; +import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard'; const MultipleSelectionMenuItems = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const selection = useAppSelector((s) => s.gallery.selection); const deleteImageModal = useDeleteImageModalApi(); + const selectedBoard = useSelectedBoard(); + const { canWriteImages } = useBoardAccess(selectedBoard); const [starImages] = useStarImagesMutation(); const [unstarImages] = useUnstarImagesMutation(); @@ -53,11 +57,16 @@ const MultipleSelectionMenuItems = () => { } onClickCapture={handleBulkDownload}> {t('gallery.downloadSelection')} - } onClickCapture={handleChangeBoard}> + } onClickCapture={handleChangeBoard} isDisabled={!canWriteImages}> {t('boards.changeBoard')} - } onClickCapture={handleDeleteSelection}> + } + onClickCapture={handleDeleteSelection} + isDisabled={!canWriteImages} + > {t('gallery.deleteSelection')} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx index 0a97bf819de..612e6361b14 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx @@ -5,6 +5,8 @@ import type { MouseEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleFill } from 'react-icons/pi'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; +import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard'; import type { ImageDTO } from 'services/api/types'; type Props = { @@ -15,6 +17,8 @@ export const GalleryItemDeleteIconButton = memo(({ imageDTO }: Props) => { const shift = useShiftModifier(); const { t } = useTranslation(); const deleteImageModal = useDeleteImageModalApi(); + const selectedBoard = useSelectedBoard(); + const { canWriteImages } = useBoardAccess(selectedBoard); const onClick = useCallback( (e: MouseEvent) => { @@ -24,7 +28,7 @@ export const GalleryItemDeleteIconButton = memo(({ imageDTO }: Props) => { [deleteImageModal, imageDTO] ); - if (!shift) { + if (!shift || !canWriteImages) { return null; } diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx index b175e4d8b09..a363d159e1d 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx @@ -5,6 +5,8 @@ import { QueueIterationsNumberInput } from 'features/queue/components/QueueItera import { useInvoke } from 'features/queue/hooks/useInvoke'; import { memo } from 'react'; import { PiLightningFill, PiSparkleFill } from 'react-icons/pi'; +import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; import { InvokeButtonTooltip } from './InvokeButtonTooltip/InvokeButtonTooltip'; @@ -14,6 +16,8 @@ export const InvokeButton = memo(() => { const queue = useInvoke(); const shift = useShiftModifier(); const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading); + const autoAddBoard = useAutoAddBoard(); + const { canWriteImages } = useBoardAccess(autoAddBoard); return ( @@ -23,7 +27,7 @@ export const InvokeButton = memo(() => { onClick={shift ? queue.enqueueFront : queue.enqueueBack} isLoading={queue.isLoading || isLoadingDynamicPrompts} loadingText={invoke} - isDisabled={queue.isDisabled} + isDisabled={queue.isDisabled || !canWriteImages} rightIcon={shift ? : } variant="solid" colorScheme="invokeYellow" diff --git a/invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts b/invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts new file mode 100644 index 00000000000..1ae22270079 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useAutoAddBoard.ts @@ -0,0 +1,21 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; + +/** + * Returns the `BoardDTO` for the board currently configured as the auto-add + * destination, or `null` when it is set to "Uncategorized" (`boardId === 'none'`) + * or when the board list has not yet loaded. + */ +export const useAutoAddBoard = () => { + const autoAddBoardId = useAppSelector(selectAutoAddBoardId); + const { board } = useListAllBoardsQuery( + { include_archived: true }, + { + selectFromResult: ({ data }) => ({ + board: data?.find((b) => b.board_id === autoAddBoardId) ?? null, + }), + } + ); + return board; +}; diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts new file mode 100644 index 00000000000..9a222024255 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardAccess.ts @@ -0,0 +1,32 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; +import type { BoardDTO } from 'services/api/types'; + +/** + * Returns permission flags for the given board based on the current user: + * - `canWriteImages`: can add / delete images in the board + * (owner or admin always; non-owner allowed only for public boards) + * - `canRenameBoard`: can rename the board (owner or admin only) + * - `canDeleteBoard`: can delete the board (owner or admin only) + * + * When `board` is null/undefined (e.g. "uncategorized"), all permissions are + * granted so that existing behaviour is preserved. + * + * When `currentUser` is null the app is running without authentication + * (single-user mode), so full access is granted unconditionally. + */ +export const useBoardAccess = (board: BoardDTO | null | undefined) => { + const currentUser = useAppSelector(selectCurrentUser); + + if (!board) { + return { canWriteImages: true, canRenameBoard: true, canDeleteBoard: true }; + } + + const isOwnerOrAdmin = !currentUser || currentUser.is_admin || board.user_id === currentUser.user_id; + + return { + canWriteImages: isOwnerOrAdmin || board.board_visibility === 'public', + canRenameBoard: isOwnerOrAdmin, + canDeleteBoard: isOwnerOrAdmin, + }; +}; diff --git a/invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts b/invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts new file mode 100644 index 00000000000..40c6d77f37f --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useSelectedBoard.ts @@ -0,0 +1,21 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; + +/** + * Returns the `BoardDTO` for the currently selected board, or `null` when the + * user is viewing "Uncategorized" (`boardId === 'none'`) or when the board list + * has not yet loaded. + */ +export const useSelectedBoard = () => { + const selectedBoardId = useAppSelector(selectSelectedBoardId); + const { board } = useListAllBoardsQuery( + { include_archived: true }, + { + selectFromResult: ({ data }) => ({ + board: data?.find((b) => b.board_id === selectedBoardId) ?? null, + }), + } + ); + return board; +}; From 9f8f7a1f022a38677cb7f137a713011d8cdc0cc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:35:45 +0000 Subject: [PATCH 3/3] Fix remaining board access enforcement: invoke icon, drag-out, change-board filter, archive Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- .../migrations/migration_29.py | 12 ++--- .../components/ChangeBoardModal.tsx | 15 +++++- .../components/Boards/BoardContextMenu.tsx | 22 +++------ .../components/ImageGrid/GalleryImage.tsx | 47 ++++++++++++------- .../components/FloatingLeftPanelButtons.tsx | 6 ++- .../frontend/web/src/services/api/schema.ts | 7 ++- 6 files changed, 62 insertions(+), 47 deletions(-) diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py index c3fa48e6377..c9eb7c901ba 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_29.py @@ -33,17 +33,11 @@ def _update_boards_table(self, cursor: sqlite3.Cursor) -> None: columns = [row[1] for row in cursor.fetchall()] if "board_visibility" not in columns: - cursor.execute( - "ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';" - ) - cursor.execute( - "CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);" - ) + cursor.execute("ALTER TABLE boards ADD COLUMN board_visibility TEXT NOT NULL DEFAULT 'private';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_board_visibility ON boards(board_visibility);") # Migrate existing is_public = 1 boards to 'public' if "is_public" in columns: - cursor.execute( - "UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;" - ) + cursor.execute("UPDATE boards SET board_visibility = 'public' WHERE is_public = 1;") def build_migration_29() -> Migration: diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 00217eb7963..5ac6ffcb7c9 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -3,6 +3,7 @@ import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@inv import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { changeBoardReset, isModalOpenChanged, @@ -13,6 +14,7 @@ import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images'; +import type { BoardDTO } from 'services/api/types'; const selectImagesToChange = createSelector( selectChangeBoardModalSlice, @@ -28,6 +30,7 @@ const ChangeBoardModal = () => { useAssertSingleton('ChangeBoardModal'); const dispatch = useAppDispatch(); const currentBoardId = useAppSelector(selectSelectedBoardId); + const currentUser = useAppSelector(selectCurrentUser); const [selectedBoardId, setSelectedBoardId] = useState(); const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true }); const isModalOpen = useAppSelector(selectIsModalOpen); @@ -36,10 +39,20 @@ const ChangeBoardModal = () => { const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation(); const { t } = useTranslation(); + // Returns true if the current user can write images to the given board. + const canWriteToBoard = useCallback( + (board: BoardDTO): boolean => { + const isOwnerOrAdmin = !currentUser || currentUser.is_admin || board.user_id === currentUser.user_id; + return isOwnerOrAdmin || board.board_visibility === 'public'; + }, + [currentUser] + ); + const options = useMemo(() => { return [{ label: t('boards.uncategorized'), value: 'none' }] .concat( (boards ?? []) + .filter(canWriteToBoard) .map((board) => ({ label: board.board_name, value: board.board_id, @@ -47,7 +60,7 @@ const ChangeBoardModal = () => { .sort((a, b) => a.label.localeCompare(b.label)) ) .filter((board) => board.value !== currentBoardId); - }, [boards, currentBoardId, t]); + }, [boards, canWriteToBoard, currentBoardId, t]); const value = useMemo(() => options.find((o) => o.value === selectedBoardId), [options, selectedBoardId]); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx index a4a4ae307a3..d10dde6ee44 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -48,8 +48,7 @@ const BoardContextMenu = ({ board, children }: Props) => { const [bulkDownload] = useBulkDownloadImagesMutation(); // Only the board owner or admin can modify visibility - const canChangeVisibility = - currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id); + const canChangeVisibility = currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id); const { canDeleteBoard } = useBoardAccess(board); @@ -96,20 +95,11 @@ const BoardContextMenu = ({ board, children }: Props) => { [board.board_id, t, updateBoard] ); - const handleSetVisibilityPrivate = useCallback( - () => handleSetVisibility('private'), - [handleSetVisibility] - ); + const handleSetVisibilityPrivate = useCallback(() => handleSetVisibility('private'), [handleSetVisibility]); - const handleSetVisibilityShared = useCallback( - () => handleSetVisibility('shared'), - [handleSetVisibility] - ); + const handleSetVisibilityShared = useCallback(() => handleSetVisibility('shared'), [handleSetVisibility]); - const handleSetVisibilityPublic = useCallback( - () => handleSetVisibility('public'), - [handleSetVisibility] - ); + const handleSetVisibilityPublic = useCallback(() => handleSetVisibility('public'), [handleSetVisibility]); const setAsBoardToDelete = useCallback(() => { $boardToDelete.set(board); @@ -130,13 +120,13 @@ const BoardContextMenu = ({ board, children }: Props) => { {board.archived && ( - } onClick={handleUnarchive}> + } onClick={handleUnarchive} isDisabled={!canDeleteBoard}> {t('boards.unarchiveBoard')} )} {!board.archived && ( - } onClick={handleArchive}> + } onClick={handleArchive} isDisabled={!canDeleteBoard}> {t('boards.archiveBoard')} )} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index ccd58992ef6..8236ffcf622 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -26,6 +26,8 @@ import type { MouseEvent, MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiImageBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; +import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard'; import type { ImageDTO } from 'services/api/types'; import { galleryItemContainerSX } from './galleryItemContainerSX'; @@ -102,12 +104,37 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { [imageDTO.image_name] ); const isSelected = useAppSelector(selectIsSelected); + const selectedBoard = useSelectedBoard(); + const { canWriteImages: canDragFromBoard } = useBoardAccess(selectedBoard); useEffect(() => { const element = ref.current; if (!element) { return; } + + const monitorBinding = monitorForElements({ + // This is a "global" drag start event, meaning that it is called for all drag events. + onDragStart: ({ source }) => { + // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the + // selection. This is called for all drag events. + if ( + multipleImageDndSource.typeGuard(source.data) && + source.data.payload.image_names.includes(imageDTO.image_name) + ) { + setIsDragging(true); + } + }, + onDrop: () => { + // Always set the dragging state to false when a drop event occurs. + setIsDragging(false); + }, + }); + + if (!canDragFromBoard) { + return combine(firefoxDndFix(element), monitorBinding); + } + return combine( firefoxDndFix(element), draggable({ @@ -153,25 +180,9 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { } }, }), - monitorForElements({ - // This is a "global" drag start event, meaning that it is called for all drag events. - onDragStart: ({ source }) => { - // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the - // selection. This is called for all drag events. - if ( - multipleImageDndSource.typeGuard(source.data) && - source.data.payload.image_names.includes(imageDTO.image_name) - ) { - setIsDragging(true); - } - }, - onDrop: () => { - // Always set the dragging state to false when a drop event occurs. - setIsDragging(false); - }, - }) + monitorBinding ); - }, [imageDTO, store]); + }, [imageDTO, store, canDragFromBoard]); const [isHovered, setIsHovered] = useState(false); diff --git a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx index 81e8930e401..c9620d84ac9 100644 --- a/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx +++ b/invokeai/frontend/web/src/features/ui/components/FloatingLeftPanelButtons.tsx @@ -17,6 +17,8 @@ import { PiXCircle, } from 'react-icons/pi'; import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; +import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard'; +import { useBoardAccess } from 'services/api/hooks/useBoardAccess'; export const FloatingLeftPanelButtons = memo(() => { return ( @@ -71,6 +73,8 @@ const InvokeIconButton = memo(() => { const { t } = useTranslation(); const queue = useInvoke(); const shift = useShiftModifier(); + const autoAddBoard = useAutoAddBoard(); + const { canWriteImages } = useBoardAccess(autoAddBoard); return ( @@ -78,7 +82,7 @@ const InvokeIconButton = memo(() => { aria-label={t('queue.queueBack')} onClick={shift ? queue.enqueueFront : queue.enqueueBack} isLoading={queue.isLoading} - isDisabled={queue.isDisabled} + isDisabled={queue.isDisabled || !canWriteImages} icon={} colorScheme="invokeYellow" flexGrow={1} diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b4d17ea3697..8cffa369101 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -3094,6 +3094,11 @@ export type components = { * @description Whether or not the board is archived. */ archived: boolean; + /** + * @description The visibility of the board. + * @default private + */ + board_visibility?: components["schemas"]["BoardVisibility"]; /** * Image Count * @description The number of images in the board. @@ -3109,8 +3114,6 @@ export type components = { * @description The username of the board owner (for admin view). */ owner_username?: string | null; - /** @description The visibility of the board. */ - board_visibility: components["schemas"]["BoardVisibility"]; }; /** * BoardField