Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
13faa0f
Add board visibility (private/shared/public) feature with tests and UI
Copilot Mar 9, 2026
f38d1ab
Enforce read-only access for non-owners of shared/public boards in UI
Copilot Mar 9, 2026
9f8f7a1
Fix remaining board access enforcement: invoke icon, drag-out, change…
Copilot Mar 10, 2026
3559a10
chore: merge with upstream
lstein Apr 4, 2026
f128121
fix: allow drag from shared boards to non-board targets (viewer, ref …
lstein Apr 4, 2026
b4276fd
Merge branch 'lstein/feature/workflow-isolation-in-multiuser-mode' in…
lstein Apr 4, 2026
23ab8f5
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 5, 2026
5596fa0
Upgrade spandrel version (#8996)
JPPhoto Apr 5, 2026
41a5425
Fix workflows info copy focus (#9015)
JPPhoto Apr 5, 2026
471ab9d
feat: add Inpaint Mask as drag & drop target on canvas (#8942)
Pfannkuchensack Apr 5, 2026
82f3dc9
Fix to retain layer opacity on mode switch. (#8879)
DustyShoe Apr 5, 2026
be015a5
Run vitest during frontend build (#9022)
JPPhoto Apr 5, 2026
01c67c5
Fix (multiuser): Ask user to log back in when security token has expi…
lstein Apr 6, 2026
e6f2980
Added `If` node and ability to link an `Any` output to a node input i…
JPPhoto Apr 6, 2026
ac4ef09
fix(security): add auth requirement to all sensitive routes in multim…
lstein Apr 6, 2026
24d0d38
Merge branch 'main' into copilot/enhancement-allow-shared-boards
lstein Apr 6, 2026
32002bd
ui: translations update from weblate (#8992)
weblate Apr 6, 2026
5ba03e9
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 7, 2026
ae42182
fix: detect Z-Image LoRAs with transformer.layers prefix (#8986)
Pfannkuchensack Apr 7, 2026
f08b802
feat: add support for OneTrainer BFL Flux LoRA format (#8984)
Pfannkuchensack Apr 7, 2026
915239b
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 7, 2026
dbbf289
fix: detect FLUX.2 Klein 9B Base variant via filename heuristic (#9011)
Pfannkuchensack Apr 7, 2026
61c884c
Merge remote-tracking branch 'refs/remotes/origin/copilot/enhancement…
lstein Apr 7, 2026
ac1f1a5
chore(backend): ruff
lstein Apr 7, 2026
80be1b7
fix: correct inaccurate download size estimates in starter models (#8…
Pfannkuchensack Apr 7, 2026
60d0bcd
Feature(UI): Canvas Workflow Integration - Run Workflow on Raster Lay…
Pfannkuchensack Apr 7, 2026
ed45bd4
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 8, 2026
f0d09c3
feat: add Anima model support (#8961)
4pointoh Apr 9, 2026
edd1258
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 9, 2026
5f35d0e
feat(frontend): suppress tooltips on touch devices (#9001)
lstein Apr 9, 2026
d4c0e63
ui: translations update from weblate (#9028)
weblate Apr 9, 2026
ee60097
Broaden text encoder partial-load recovery (#9034)
JPPhoto Apr 10, 2026
b86e289
fix (backend): improve user isolation for session queue and recall pa…
lstein Apr 10, 2026
797638b
fix(workflow): do not filter default workflows in multiuser mode
lstein Apr 10, 2026
8f792fc
Merge branch 'main' into copilot/enhancement-allow-shared-boards
lstein Apr 10, 2026
d4104be
`graph.py` refactoring and `If` node optimization (#9030)
JPPhoto Apr 10, 2026
c7eeb26
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 10, 2026
06eff38
fix(ui): replace all hardcoded frontend strings with i18n translation…
Pfannkuchensack Apr 10, 2026
a2e4fbb
fix: patch openapi-typescript enum generation to match OpenAPI schema…
lstein Apr 10, 2026
3c9b282
Redesign Model Manager Installation Queue (#8910)
joshistoast Apr 10, 2026
a350712
feat: add configurable shift parameter for Z-Image (#9004)
Pfannkuchensack Apr 10, 2026
763944b
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 10, 2026
79de869
Merge branch 'lstein/feature/workflow-isolation-in-multiuser-mode' in…
lstein Apr 10, 2026
5ce3c17
fix(multiuser): scope queue/recall/intermediates endpoints to current…
lstein Apr 10, 2026
59d6d27
chore(backend): ruff
lstein Apr 10, 2026
ef082af
fix(multiuser): reject anonymous websockets and scope queue item events
lstein Apr 11, 2026
2403177
fix(multiuser): verify user record on websocket connect
lstein Apr 12, 2026
b42274a
Feat[model support]: Qwen Image — full pipeline with edit, generate L…
lstein Apr 12, 2026
c7bce4f
Merge branch 'main' into copilot/enhancement-allow-shared-boards
JPPhoto Apr 12, 2026
494fc15
fix(multiuser): close bulk download cross-user exfiltration path
lstein Apr 12, 2026
8182c08
fix(multiuser): enforce board visibility on image listing endpoints
lstein Apr 12, 2026
5d589ab
chore(backend): ruff
lstein Apr 12, 2026
345d039
fix(multiuser): require image ownership when adding images to boards
lstein Apr 12, 2026
9703812
chore(backend): ruff
lstein Apr 12, 2026
9e7354d
fix(multiuser): validate image access in recall parameter resolution
lstein Apr 12, 2026
a6308b4
fix(multiuser): require admin auth on model install job endpoints
lstein Apr 12, 2026
58cb8aa
fix(multiuser): close bulk download exfiltration and additional revie…
lstein Apr 12, 2026
95c6e3c
fix(multiuser): add user_id scoping to workflow SQL mutations
lstein Apr 12, 2026
c19f040
fix(multiuser): allow non-owner uploads to public boards
lstein Apr 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions invokeai/app/api/routers/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions invokeai/app/services/board_records/board_records_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -54,13 +74,15 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord:
updated_at=updated_at,
deleted_at=deleted_at,
archived=archived,
board_visibility=board_visibility,
)


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):
Expand Down
21 changes: 16 additions & 5 deletions invokeai/app/services/board_records/board_records_sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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)
Expand Down Expand Up @@ -155,7 +166,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 ?;
Expand Down Expand Up @@ -194,14 +205,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;
"""

Expand Down Expand Up @@ -251,7 +262,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}
"""
Expand All @@ -260,7 +271,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}
"""
Expand Down
2 changes: 2 additions & 0 deletions invokeai/app/services/shared/sqlite/sqlite_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""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(),
)
12 changes: 11 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -28,6 +30,7 @@ const ChangeBoardModal = () => {
useAssertSingleton('ChangeBoardModal');
const dispatch = useAppDispatch();
const currentBoardId = useAppSelector(selectSelectedBoardId);
const currentUser = useAppSelector(selectCurrentUser);
const [selectedBoardId, setSelectedBoardId] = useState<string | null>();
const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true });
const isModalOpen = useAppSelector(selectIsModalOpen);
Expand All @@ -36,18 +39,28 @@ 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<ComboboxOption[]>(() => {
return [{ label: t('boards.uncategorized'), value: 'none' }]
.concat(
(boards ?? [])
.filter(canWriteToBoard)
.map((board) => ({
label: board.board_name,
value: board.board_id,
}))
.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]);

Expand Down
Loading
Loading