Skip to content

Commit a0b90b1

Browse files
Copilotlstein
andcommitted
Enforce read-only access for non-owners of shared/public boards in UI
Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
1 parent 0d7f7ea commit a0b90b1

11 files changed

Lines changed: 133 additions & 10 deletions

File tree

invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from 'react-icons/pi';
2222
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
2323
import { useBulkDownloadImagesMutation } from 'services/api/endpoints/images';
24+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
2425
import { useBoardName } from 'services/api/hooks/useBoardName';
2526
import type { BoardDTO } from 'services/api/types';
2627

@@ -50,6 +51,8 @@ const BoardContextMenu = ({ board, children }: Props) => {
5051
const canChangeVisibility =
5152
currentUser !== null && (currentUser.is_admin || board.user_id === currentUser.user_id);
5253

54+
const { canDeleteBoard } = useBoardAccess(board);
55+
5356
const handleSetAutoAdd = useCallback(() => {
5457
dispatch(autoAddBoardIdChanged(board.board_id));
5558
}, [board.board_id, dispatch]);
@@ -164,7 +167,13 @@ const BoardContextMenu = ({ board, children }: Props) => {
164167
</>
165168
)}
166169

167-
<MenuItem color="error.300" icon={<PiTrashSimpleBold />} onClick={setAsBoardToDelete} isDestructive>
170+
<MenuItem
171+
color="error.300"
172+
icon={<PiTrashSimpleBold />}
173+
onClick={setAsBoardToDelete}
174+
isDestructive
175+
isDisabled={!canDeleteBoard}
176+
>
168177
{t('boards.deleteBoard')}
169178
</MenuItem>
170179
</MenuGroup>
@@ -185,6 +194,7 @@ const BoardContextMenu = ({ board, children }: Props) => {
185194
handleSetVisibilityPrivate,
186195
handleSetVisibilityShared,
187196
handleSetVisibilityPublic,
197+
canDeleteBoard,
188198
setAsBoardToDelete,
189199
]
190200
);

invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardEditableTitle.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { memo, useCallback, useRef } from 'react';
77
import { useTranslation } from 'react-i18next';
88
import { PiPencilBold } from 'react-icons/pi';
99
import { useUpdateBoardMutation } from 'services/api/endpoints/boards';
10+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
1011
import type { BoardDTO } from 'services/api/types';
1112

1213
type Props = {
@@ -19,6 +20,7 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
1920
const isHovering = useBoolean(false);
2021
const inputRef = useRef<HTMLInputElement>(null);
2122
const [updateBoard, updateBoardResult] = useUpdateBoardMutation();
23+
const { canRenameBoard } = useBoardAccess(board);
2224

2325
const onChange = useCallback(
2426
async (board_name: string) => {
@@ -51,13 +53,13 @@ export const BoardEditableTitle = memo(({ board, isSelected }: Props) => {
5153
fontWeight="semibold"
5254
userSelect="none"
5355
color={isSelected ? 'base.100' : 'base.300'}
54-
onDoubleClick={editable.startEditing}
55-
cursor="text"
56+
onDoubleClick={canRenameBoard ? editable.startEditing : undefined}
57+
cursor={canRenameBoard ? 'text' : 'default'}
5658
noOfLines={1}
5759
>
5860
{editable.value}
5961
</Text>
60-
{isHovering.isTrue && (
62+
{canRenameBoard && isHovering.isTrue && (
6163
<IconButton
6264
aria-label="edit name"
6365
icon={<PiPencilBold />}

invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { memo, useCallback, useMemo } from 'react';
2020
import { useTranslation } from 'react-i18next';
2121
import { PiArchiveBold, PiGlobeBold, PiImageSquare, PiShareNetworkBold } from 'react-icons/pi';
2222
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
23+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
2324
import type { BoardDTO } from 'services/api/types';
2425

2526
const _hover: SystemStyleObject = {
@@ -62,6 +63,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
6263

6364
const showOwner = currentUser?.is_admin && board.owner_username;
6465

66+
const { canWriteImages } = useBoardAccess(board);
67+
6568
return (
6669
<Box position="relative" w="full" h={12}>
6770
<BoardContextMenu board={board}>
@@ -122,7 +125,12 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => {
122125
</Tooltip>
123126
)}
124127
</BoardContextMenu>
125-
<DndDropTarget dndTarget={addImageToBoardDndTarget} dndTargetData={dndTargetData} label={t('gallery.move')} />
128+
<DndDropTarget
129+
dndTarget={addImageToBoardDndTarget}
130+
dndTargetData={dndTargetData}
131+
label={t('gallery.move')}
132+
isDisabled={!canWriteImages}
133+
/>
126134
</Box>
127135
);
128136
};

invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,23 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
55
import { memo, useCallback } from 'react';
66
import { useTranslation } from 'react-i18next';
77
import { PiFoldersBold } from 'react-icons/pi';
8+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
9+
import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
810

911
export const ContextMenuItemChangeBoard = memo(() => {
1012
const { t } = useTranslation();
1113
const dispatch = useAppDispatch();
1214
const imageDTO = useImageDTOContext();
15+
const selectedBoard = useSelectedBoard();
16+
const { canWriteImages } = useBoardAccess(selectedBoard);
1317

1418
const onClick = useCallback(() => {
1519
dispatch(imagesToChangeSelected([imageDTO.image_name]));
1620
dispatch(isModalOpenChanged(true));
1721
}, [dispatch, imageDTO]);
1822

1923
return (
20-
<MenuItem icon={<PiFoldersBold />} onClickCapture={onClick}>
24+
<MenuItem icon={<PiFoldersBold />} onClickCapture={onClick} isDisabled={!canWriteImages}>
2125
{t('boards.changeBoard')}
2226
</MenuItem>
2327
);

invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDeleteImage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@ import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext';
44
import { memo, useCallback } from 'react';
55
import { useTranslation } from 'react-i18next';
66
import { PiTrashSimpleBold } from 'react-icons/pi';
7+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
8+
import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
79

810
export const ContextMenuItemDeleteImage = memo(() => {
911
const { t } = useTranslation();
1012
const deleteImageModal = useDeleteImageModalApi();
1113
const imageDTO = useImageDTOContext();
14+
const selectedBoard = useSelectedBoard();
15+
const { canWriteImages } = useBoardAccess(selectedBoard);
1216

1317
const onClick = useCallback(async () => {
1418
try {
@@ -18,6 +22,10 @@ export const ContextMenuItemDeleteImage = memo(() => {
1822
}
1923
}, [deleteImageModal, imageDTO]);
2024

25+
if (!canWriteImages) {
26+
return null;
27+
}
28+
2129
return (
2230
<IconMenuItem
2331
icon={<PiTrashSimpleBold />}

invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ import {
1010
useStarImagesMutation,
1111
useUnstarImagesMutation,
1212
} from 'services/api/endpoints/images';
13+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
14+
import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
1315

1416
const MultipleSelectionMenuItems = () => {
1517
const { t } = useTranslation();
1618
const dispatch = useAppDispatch();
1719
const selection = useAppSelector((s) => s.gallery.selection);
1820
const deleteImageModal = useDeleteImageModalApi();
21+
const selectedBoard = useSelectedBoard();
22+
const { canWriteImages } = useBoardAccess(selectedBoard);
1923

2024
const [starImages] = useStarImagesMutation();
2125
const [unstarImages] = useUnstarImagesMutation();
@@ -53,11 +57,16 @@ const MultipleSelectionMenuItems = () => {
5357
<MenuItem icon={<PiDownloadSimpleBold />} onClickCapture={handleBulkDownload}>
5458
{t('gallery.downloadSelection')}
5559
</MenuItem>
56-
<MenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard}>
60+
<MenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard} isDisabled={!canWriteImages}>
5761
{t('boards.changeBoard')}
5862
</MenuItem>
5963
<MenuDivider />
60-
<MenuItem color="error.300" icon={<PiTrashSimpleBold />} onClickCapture={handleDeleteSelection}>
64+
<MenuItem
65+
color="error.300"
66+
icon={<PiTrashSimpleBold />}
67+
onClickCapture={handleDeleteSelection}
68+
isDisabled={!canWriteImages}
69+
>
6170
{t('gallery.deleteSelection')}
6271
</MenuItem>
6372
</>

invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { MouseEvent } from 'react';
55
import { memo, useCallback } from 'react';
66
import { useTranslation } from 'react-i18next';
77
import { PiTrashSimpleFill } from 'react-icons/pi';
8+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
9+
import { useSelectedBoard } from 'services/api/hooks/useSelectedBoard';
810
import type { ImageDTO } from 'services/api/types';
911

1012
type Props = {
@@ -15,6 +17,8 @@ export const GalleryItemDeleteIconButton = memo(({ imageDTO }: Props) => {
1517
const shift = useShiftModifier();
1618
const { t } = useTranslation();
1719
const deleteImageModal = useDeleteImageModalApi();
20+
const selectedBoard = useSelectedBoard();
21+
const { canWriteImages } = useBoardAccess(selectedBoard);
1822

1923
const onClick = useCallback(
2024
(e: MouseEvent<HTMLButtonElement>) => {
@@ -24,7 +28,7 @@ export const GalleryItemDeleteIconButton = memo(({ imageDTO }: Props) => {
2428
[deleteImageModal, imageDTO]
2529
);
2630

27-
if (!shift) {
31+
if (!shift || !canWriteImages) {
2832
return null;
2933
}
3034

invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { QueueIterationsNumberInput } from 'features/queue/components/QueueItera
55
import { useInvoke } from 'features/queue/hooks/useInvoke';
66
import { memo } from 'react';
77
import { PiLightningFill, PiSparkleFill } from 'react-icons/pi';
8+
import { useAutoAddBoard } from 'services/api/hooks/useAutoAddBoard';
9+
import { useBoardAccess } from 'services/api/hooks/useBoardAccess';
810

911
import { InvokeButtonTooltip } from './InvokeButtonTooltip/InvokeButtonTooltip';
1012

@@ -14,6 +16,8 @@ export const InvokeButton = memo(() => {
1416
const queue = useInvoke();
1517
const shift = useShiftModifier();
1618
const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading);
19+
const autoAddBoard = useAutoAddBoard();
20+
const { canWriteImages } = useBoardAccess(autoAddBoard);
1721

1822
return (
1923
<Flex pos="relative" w="200px">
@@ -23,7 +27,7 @@ export const InvokeButton = memo(() => {
2327
onClick={shift ? queue.enqueueFront : queue.enqueueBack}
2428
isLoading={queue.isLoading || isLoadingDynamicPrompts}
2529
loadingText={invoke}
26-
isDisabled={queue.isDisabled}
30+
isDisabled={queue.isDisabled || !canWriteImages}
2731
rightIcon={shift ? <PiLightningFill /> : <PiSparkleFill />}
2832
variant="solid"
2933
colorScheme="invokeYellow"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useAppSelector } from 'app/store/storeHooks';
2+
import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors';
3+
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
4+
5+
/**
6+
* Returns the `BoardDTO` for the board currently configured as the auto-add
7+
* destination, or `null` when it is set to "Uncategorized" (`boardId === 'none'`)
8+
* or when the board list has not yet loaded.
9+
*/
10+
export const useAutoAddBoard = () => {
11+
const autoAddBoardId = useAppSelector(selectAutoAddBoardId);
12+
const { board } = useListAllBoardsQuery(
13+
{ include_archived: true },
14+
{
15+
selectFromResult: ({ data }) => ({
16+
board: data?.find((b) => b.board_id === autoAddBoardId) ?? null,
17+
}),
18+
}
19+
);
20+
return board;
21+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useAppSelector } from 'app/store/storeHooks';
2+
import { selectCurrentUser } from 'features/auth/store/authSlice';
3+
import type { BoardDTO } from 'services/api/types';
4+
5+
/**
6+
* Returns permission flags for the given board based on the current user:
7+
* - `canWriteImages`: can add / delete images in the board
8+
* (owner or admin always; non-owner allowed only for public boards)
9+
* - `canRenameBoard`: can rename the board (owner or admin only)
10+
* - `canDeleteBoard`: can delete the board (owner or admin only)
11+
*
12+
* When `board` is null/undefined (e.g. "uncategorized"), all permissions are
13+
* granted so that existing behaviour is preserved.
14+
*
15+
* When `currentUser` is null the app is running without authentication
16+
* (single-user mode), so full access is granted unconditionally.
17+
*/
18+
export const useBoardAccess = (board: BoardDTO | null | undefined) => {
19+
const currentUser = useAppSelector(selectCurrentUser);
20+
21+
if (!board) {
22+
return { canWriteImages: true, canRenameBoard: true, canDeleteBoard: true };
23+
}
24+
25+
const isOwnerOrAdmin = !currentUser || currentUser.is_admin || board.user_id === currentUser.user_id;
26+
27+
return {
28+
canWriteImages: isOwnerOrAdmin || board.board_visibility === 'public',
29+
canRenameBoard: isOwnerOrAdmin,
30+
canDeleteBoard: isOwnerOrAdmin,
31+
};
32+
};

0 commit comments

Comments
 (0)