Skip to content

Commit e252a5b

Browse files
authored
fix(multiuser): make preexisting workflows visible after migration (#9049)
1 parent ce89667 commit e252a5b

File tree

9 files changed

+238
-34
lines changed

9 files changed

+238
-34
lines changed

docs/multiuser/user_guide.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,13 @@ As a regular user, you can:
140140
- ✅ View your own generation queue
141141
- ✅ Customize your UI preferences (theme, hotkeys, etc.)
142142
- ✅ View available models (read-only access to Model Manager)
143-
- Access shared boards (based on permissions granted to you) (FUTURE FEATURE)
144-
- Access workflows marked as public (FUTURE FEATURE)
143+
- View shared and public boards created by other users
144+
- View and use workflows marked as shared by other users
145145

146146
You cannot:
147147

148148
- ❌ Add, delete, or modify models
149-
- ❌ View or modify other users' boards, images, or workflows
149+
- ❌ View or modify other users' private boards, images, or workflows
150150
- ❌ Manage user accounts
151151
- ❌ Access system configuration
152152
- ❌ View or cancel other users' generation tasks
@@ -173,7 +173,7 @@ Administrators have all regular user capabilities, plus:
173173
- ✅ Full model management (add, delete, configure models)
174174
- ✅ Create and manage user accounts
175175
- ✅ View and manage all users' generation queues
176-
- Create and manage shared boards (FUTURE FEATURE)
176+
- View and manage all users' boards, images, and workflows (including system-owned legacy content)
177177
- ✅ Access system configuration
178178
- ✅ Grant or revoke admin privileges
179179

@@ -183,23 +183,30 @@ Administrators have all regular user capabilities, plus:
183183

184184
### Image Boards
185185

186-
In multi-user model, Image Boards work as before. Each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards are private: you cannot see a board owned by a different user.
186+
In multi-user mode, each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards have three visibility levels:
187187

188-
!!! tip "Shared Boards"
189-
InvokeAI 6.13 will add support for creating public boards that are accessible to all users.
188+
- **Private** (default): Only you (and administrators) can see and modify the board.
189+
- **Shared**: All users can view the board and its contents, but only you (and administrators) can modify it (rename, archive, delete, or add/remove images).
190+
- **Public**: All users can view the board. Only you (and administrators) can modify the board's structure (rename, archive, delete).
190191

191-
The Administrator can see all users Image Boards and their contents.
192+
To change a board's visibility, right-click on the board and select the desired visibility option.
192193

193-
### Going From Multi-User to Single-User mode
194+
Administrators can see and manage all users' image boards and their contents regardless of visibility settings.
195+
196+
### Going From Multi-User to Single-User Mode
194197

195198
If an InvokeAI instance was in multiuser mode and then restarted in single user mode (by setting `multiuser: false` in the configuration file), all users' boards will be consolidated in one place. Any images that were in "Uncategorized" will be merged together into a single Uncategorized board. If, at a later date, the server is restarted in multi-user mode, the boards and images will be separated and restored to their owners.
196199

197200
### Workflows
198201

199-
In the current released version (6.12) workflows are always shared among users. Any workflow that you create will be visible to other users and vice-versa, and there is no protection against one user modifying another user's workflow.
202+
Each user has their own private workflow library. Workflows you create are visible only to you by default.
203+
204+
You can share a workflow with other users by marking it as **shared** (public). Shared workflows appear in all users' workflow libraries and can be opened by anyone, but only the owner (or an administrator) can modify or delete them.
205+
206+
To share a workflow, open it and use the sharing controls to toggle its public/shared status.
200207

201-
!!! tip "Private and Shared Workflows"
202-
InvokeAI 6.13 will provide the ability to create private and shared workflows. A private workflow can only be viewed by the user who created it. At any time, however, the user can designate the workflow *shared*, in which case it can be opened on a read-only basis by all logged-in users.
208+
!!! warning "Preexisting workflows after enabling multi-user mode"
209+
When you enable multi-user mode for the first time on an existing InvokeAI installation, all workflows that were created before multi-user mode was activated will appear in the **shared workflows** section. These preexisting workflows are owned by the internal "system" account and are visible to all users. Administrators can edit or delete these shared legacy workflows. Regular users can view and use them but cannot modify them.
203210

204211

205212
### The Generation Queue
@@ -330,11 +337,11 @@ These settings are stored per-user and won't affect other users.
330337

331338
### Can other users see my images?
332339

333-
No, unless you add them to a shared board (FUTURE FEATURE). All your personal boards and images are private.
340+
Not unless you change your board's visibility to "shared" or "public". All personal boards and images are private by default.
334341

335342
### Can I share my workflows with others?
336343

337-
Not directly. Ask your administrator to mark workflows as public if you want to share them.
344+
Yes. You can mark any workflow as shared (public), which makes it visible to all users. Other users can view and use shared workflows, but only you or an administrator can modify or delete them.
338345

339346
### How long do sessions last?
340347

invokeai/app/api/routers/workflows.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,13 @@ async def create_workflow(
117117
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
118118
) -> WorkflowRecordDTO:
119119
"""Creates a workflow"""
120-
return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id)
120+
# In single-user mode, workflows are owned by 'system' and shared by default so all legacy/single-user
121+
# workflows remain visible. In multiuser mode, workflows are private to the creator by default.
122+
config = ApiDependencies.invoker.services.configuration
123+
is_public = not config.multiuser
124+
return ApiDependencies.invoker.services.workflow_records.create(
125+
workflow=workflow, user_id=current_user.user_id, is_public=is_public
126+
)
121127

122128

123129
@workflows_router.get(
@@ -144,10 +150,10 @@ async def list_workflows(
144150
"""Gets a page of workflows"""
145151
config = ApiDependencies.invoker.services.configuration
146152

147-
# In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows
153+
# In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows.
154+
# Admins skip the user_id filter so they can see and manage all workflows including system-owned ones.
148155
user_id_filter: Optional[str] = None
149-
if config.multiuser:
150-
# Only filter 'user' category results by user_id when not explicitly listing public workflows
156+
if config.multiuser and not current_user.is_admin:
151157
has_user_category = not categories or WorkflowCategory.User in categories
152158
if has_user_category and is_public is not True:
153159
user_id_filter = current_user.user_id
@@ -320,7 +326,7 @@ async def get_all_tags(
320326
"""Gets all unique tags from workflows"""
321327
config = ApiDependencies.invoker.services.configuration
322328
user_id_filter: Optional[str] = None
323-
if config.multiuser:
329+
if config.multiuser and not current_user.is_admin:
324330
has_user_category = not categories or WorkflowCategory.User in categories
325331
if has_user_category and is_public is not True:
326332
user_id_filter = current_user.user_id
@@ -341,7 +347,7 @@ async def get_counts_by_tag(
341347
"""Counts workflows by tag"""
342348
config = ApiDependencies.invoker.services.configuration
343349
user_id_filter: Optional[str] = None
344-
if config.multiuser:
350+
if config.multiuser and not current_user.is_admin:
345351
has_user_category = not categories or WorkflowCategory.User in categories
346352
if has_user_category and is_public is not True:
347353
user_id_filter = current_user.user_id
@@ -361,7 +367,7 @@ async def counts_by_category(
361367
"""Counts workflows by category"""
362368
config = ApiDependencies.invoker.services.configuration
363369
user_id_filter: Optional[str] = None
364-
if config.multiuser:
370+
if config.multiuser and not current_user.is_admin:
365371
has_user_category = WorkflowCategory.User in categories
366372
if has_user_category and is_public is not True:
367373
user_id_filter = current_user.user_id

invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None:
2929
if "is_public" not in columns:
3030
cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
3131
cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
32+
cursor.execute(
33+
"UPDATE workflow_library SET is_public = TRUE WHERE user_id = 'system';"
34+
) # one-time fix for legacy workflows
3235

3336

3437
def build_migration_28() -> Migration:

invokeai/app/services/workflow_records/workflow_records_base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
2323
pass
2424

2525
@abstractmethod
26-
def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
26+
def create(
27+
self,
28+
workflow: WorkflowWithoutID,
29+
user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID,
30+
is_public: bool = False,
31+
) -> WorkflowRecordDTO:
2732
"""Creates a workflow."""
2833
pass
2934

invokeai/app/services/workflow_records/workflow_records_sqlite.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
4848
raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
4949
return WorkflowRecordDTO.from_dict(dict(row))
5050

51-
def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
51+
def create(
52+
self,
53+
workflow: WorkflowWithoutID,
54+
user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID,
55+
is_public: bool = False,
56+
) -> WorkflowRecordDTO:
5257
if workflow.meta.category is WorkflowCategory.Default:
5358
raise ValueError("Default workflows cannot be created via this method")
5459

@@ -59,11 +64,12 @@ def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DE
5964
INSERT OR IGNORE INTO workflow_library (
6065
workflow_id,
6166
workflow,
62-
user_id
67+
user_id,
68+
is_public
6369
)
64-
VALUES (?, ?, ?);
70+
VALUES (?, ?, ?, ?);
6571
""",
66-
(workflow_with_id.id, workflow_with_id.model_dump_json(), user_id),
72+
(workflow_with_id.id, workflow_with_id.model_dump_json(), user_id, is_public),
6773
)
6874
return self.get(workflow_with_id.id)
6975

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,22 @@ import { memo, useCallback, useMemo } from 'react';
3131
import { useTranslation } from 'react-i18next';
3232
import { PiArrowCounterClockwiseBold, PiStarFill } from 'react-icons/pi';
3333
import { useDispatch } from 'react-redux';
34+
import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
3435
import { useGetAllTagsQuery, useGetCountsByTagQuery } from 'services/api/endpoints/workflows';
3536

3637
export const WorkflowLibrarySideNav = () => {
3738
const { t } = useTranslation();
39+
const { data: setupStatus } = useGetSetupStatusQuery();
40+
const multiuserEnabled = setupStatus?.multiuser_enabled ?? false;
3841

3942
return (
4043
<Flex h="full" minH={0} overflow="hidden" flexDir="column" w={64} gap={0}>
4144
<Flex flexDir="column" w="full" pb={2} gap={2}>
4245
<WorkflowLibraryViewButton view="recent">{t('workflows.recentlyOpened')}</WorkflowLibraryViewButton>
4346
<YourWorkflowsButton />
44-
<WorkflowLibraryViewButton view="shared">{t('workflows.sharedWorkflows')}</WorkflowLibraryViewButton>
47+
{multiuserEnabled && (
48+
<WorkflowLibraryViewButton view="shared">{t('workflows.sharedWorkflows')}</WorkflowLibraryViewButton>
49+
)}
4550
</Flex>
4651
<Flex h="full" minH={0} overflow="hidden" flexDir="column">
4752
<BrowseWorkflowsButton />

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
99
import { type ChangeEvent, memo, type MouseEvent, useCallback, useMemo } from 'react';
1010
import { useTranslation } from 'react-i18next';
1111
import { PiImage } from 'react-icons/pi';
12+
import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
1213
import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
1314
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
1415

@@ -36,6 +37,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
3637
const dispatch = useAppDispatch();
3738
const workflowId = useAppSelector(selectWorkflowId);
3839
const currentUser = useAppSelector(selectCurrentUser);
40+
const { data: setupStatus } = useGetSetupStatusQuery();
3941
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
4042

4143
const isActive = useMemo(() => {
@@ -47,8 +49,12 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
4749
}, [currentUser, workflow.user_id]);
4850

4951
const canEditOrDelete = useMemo(() => {
52+
// In single-user (legacy) mode, all workflows are editable — no concept of ownership.
53+
if (!setupStatus?.multiuser_enabled) {
54+
return true;
55+
}
5056
return isOwner || (currentUser?.is_admin ?? false);
51-
}, [isOwner, currentUser]);
57+
}, [setupStatus?.multiuser_enabled, isOwner, currentUser]);
5258

5359
const tags = useMemo(() => {
5460
if (!workflow.tags) {
@@ -113,7 +119,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
113119
{t('workflows.opened')}
114120
</Badge>
115121
)}
116-
{workflow.is_public && workflow.category !== 'default' && (
122+
{setupStatus?.multiuser_enabled && workflow.is_public && workflow.category !== 'default' && (
117123
<Badge
118124
color="invokeGreen.400"
119125
borderColor="invokeGreen.700"
@@ -160,7 +166,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
160166
</Text>
161167
)}
162168
<Spacer />
163-
{isOwner && <ShareWorkflowToggle workflow={workflow} />}
169+
{setupStatus?.multiuser_enabled && canEditOrDelete && <ShareWorkflowToggle workflow={workflow} />}
164170
{workflow.category === 'default' && <ViewWorkflow workflowId={workflow.workflow_id} />}
165171
{workflow.category !== 'default' && (
166172
<>

invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { t } from 'i18next';
2020
import { atom, computed } from 'nanostores';
2121
import type { ChangeEvent, RefObject } from 'react';
2222
import { memo, useCallback, useRef, useState } from 'react';
23+
import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
2324
import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
2425
import { assert } from 'tsafe';
2526

@@ -90,6 +91,8 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
9091
return '';
9192
});
9293
const [isPublic, setIsPublic] = useState(false);
94+
const { data: setupStatus } = useGetSetupStatusQuery();
95+
const multiuserEnabled = setupStatus?.multiuser_enabled ?? false;
9396

9497
const { createNewWorkflow } = useCreateLibraryWorkflow();
9598
const [updateIsPublic] = useUpdateWorkflowIsPublicMutation();
@@ -143,10 +146,12 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
143146
<FormLabel mt="2">{t('workflows.workflowName')}</FormLabel>
144147
<Flex flexDir="column" width="full" gap="2">
145148
<Input ref={inputRef} value={name} onChange={onChange} placeholder={t('workflows.workflowName')} />
146-
<Flex alignItems="center" gap={2}>
147-
<Checkbox isChecked={isPublic} onChange={onChangeIsPublic} />
148-
<FormLabel mb={0}>{t('workflows.shareWorkflow')}</FormLabel>
149-
</Flex>
149+
{multiuserEnabled && (
150+
<Flex alignItems="center" gap={2}>
151+
<Checkbox isChecked={isPublic} onChange={onChangeIsPublic} />
152+
<FormLabel mb={0}>{t('workflows.shareWorkflow')}</FormLabel>
153+
</Flex>
154+
)}
150155
</Flex>
151156
</FormControl>
152157
</AlertDialogBody>

0 commit comments

Comments
 (0)