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