diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py
index 2e7e49c41e3..ebb08109afc 100644
--- a/invokeai/app/api/routers/auth.py
+++ b/invokeai/app/api/routers/auth.py
@@ -79,6 +79,7 @@ class SetupStatusResponse(BaseModel):
setup_required: bool = Field(description="Whether initial setup is required")
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
+ admin_email: str | None = Field(default=None, description="Email of the first active admin user, if any")
@auth_router.get("/status", response_model=SetupStatusResponse)
@@ -92,13 +93,14 @@ async def get_setup_status() -> SetupStatusResponse:
# If multiuser is disabled, setup is never required
if not config.multiuser:
- return SetupStatusResponse(setup_required=False, multiuser_enabled=False)
+ return SetupStatusResponse(setup_required=False, multiuser_enabled=False, admin_email=None)
# In multiuser mode, check if an admin exists
user_service = ApiDependencies.invoker.services.users
setup_required = not user_service.has_admin()
+ admin_email = user_service.get_admin_email()
- return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True)
+ return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True, admin_email=admin_email)
@auth_router.post("/login", response_model=LoginResponse)
diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py
index 9d5b41e7f5f..fc0625cac76 100644
--- a/invokeai/app/api/routers/model_manager.py
+++ b/invokeai/app/api/routers/model_manager.py
@@ -1214,7 +1214,7 @@ class DeleteOrphanedModelsResponse(BaseModel):
operation_id="get_orphaned_models",
response_model=list[OrphanedModelInfo],
)
-async def get_orphaned_models() -> list[OrphanedModelInfo]:
+async def get_orphaned_models(_: AdminUserOrDefault) -> list[OrphanedModelInfo]:
"""Find orphaned model directories.
Orphaned models are directories in the models folder that contain model files
@@ -1241,7 +1241,9 @@ async def get_orphaned_models() -> list[OrphanedModelInfo]:
operation_id="delete_orphaned_models",
response_model=DeleteOrphanedModelsResponse,
)
-async def delete_orphaned_models(request: DeleteOrphanedModelsRequest) -> DeleteOrphanedModelsResponse:
+async def delete_orphaned_models(
+ request: DeleteOrphanedModelsRequest, _: AdminUserOrDefault
+) -> DeleteOrphanedModelsResponse:
"""Delete specified orphaned model directories.
Args:
diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 72d50a416b4..7e34660a1df 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -6,6 +6,7 @@
from fastapi.responses import FileResponse
from PIL import Image
+from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
@@ -33,16 +34,25 @@
},
)
async def get_workflow(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to get"),
) -> WorkflowRecordWithThumbnailDTO:
"""Gets a workflow"""
try:
- thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
- return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ is_default = workflow.workflow.meta.category is WorkflowCategory.Default
+ is_owner = workflow.user_id == current_user.user_id
+ if not (is_default or is_owner or workflow.is_public or current_user.is_admin):
+ raise HTTPException(status_code=403, detail="Not authorized to access this workflow")
+
+ thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id)
+ return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump())
+
@workflows_router.patch(
"/i/{workflow_id}",
@@ -52,9 +62,18 @@ async def get_workflow(
},
)
async def update_workflow(
+ current_user: CurrentUserOrDefault,
workflow: Workflow = Body(description="The updated workflow", embed=True),
) -> WorkflowRecordDTO:
"""Updates a workflow"""
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow.id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ if not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow)
@@ -63,9 +82,18 @@ async def update_workflow(
operation_id="delete_workflow",
)
async def delete_workflow(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to delete"),
) -> None:
"""Deletes a workflow"""
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser:
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+ if not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to delete this workflow")
try:
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
except WorkflowThumbnailFileNotFoundException:
@@ -82,10 +110,11 @@ async def delete_workflow(
},
)
async def create_workflow(
+ current_user: CurrentUserOrDefault,
workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True),
) -> WorkflowRecordDTO:
"""Creates a workflow"""
- return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow)
+ return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow, user_id=current_user.user_id)
@workflows_router.get(
@@ -96,6 +125,7 @@ async def create_workflow(
},
)
async def list_workflows(
+ current_user: CurrentUserOrDefault,
page: int = Query(default=0, description="The page to get"),
per_page: Optional[int] = Query(default=None, description="The number of workflows per page"),
order_by: WorkflowRecordOrderBy = Query(
@@ -106,8 +136,19 @@ async def list_workflows(
tags: Optional[list[str]] = Query(default=None, description="The tags of workflow to get"),
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
"""Gets a page of workflows"""
+ config = ApiDependencies.invoker.services.configuration
+
+ # In multiuser mode, scope user-category workflows to the current user unless fetching shared workflows
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ # Only filter 'user' category results by user_id when not explicitly listing public workflows
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
workflows = ApiDependencies.invoker.services.workflow_records.get_many(
order_by=order_by,
@@ -118,6 +159,8 @@ async def list_workflows(
categories=categories,
tags=tags,
has_been_opened=has_been_opened,
+ user_id=user_id_filter,
+ is_public=is_public,
)
for workflow in workflows.items:
workflows_with_thumbnails.append(
@@ -143,15 +186,20 @@ async def list_workflows(
},
)
async def set_workflow_thumbnail(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to update"),
image: UploadFile = File(description="The image file to upload"),
):
"""Sets a workflow's thumbnail image"""
try:
- ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
@@ -177,14 +225,19 @@ async def set_workflow_thumbnail(
},
)
async def delete_workflow_thumbnail(
+ current_user: CurrentUserOrDefault,
workflow_id: str = Path(description="The workflow to update"),
):
"""Removes a workflow's thumbnail image"""
try:
- ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
except WorkflowNotFoundError:
raise HTTPException(status_code=404, detail="Workflow not found")
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
try:
ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id)
except ValueError as e:
@@ -223,37 +276,90 @@ async def get_workflow_thumbnail(
raise HTTPException(status_code=404)
+@workflows_router.patch(
+ "/i/{workflow_id}/is_public",
+ operation_id="update_workflow_is_public",
+ responses={
+ 200: {"model": WorkflowRecordDTO},
+ },
+)
+async def update_workflow_is_public(
+ current_user: CurrentUserOrDefault,
+ workflow_id: str = Path(description="The workflow to update"),
+ is_public: bool = Body(description="Whether the workflow should be shared publicly", embed=True),
+) -> WorkflowRecordDTO:
+ """Updates whether a workflow is shared publicly"""
+ try:
+ existing = ApiDependencies.invoker.services.workflow_records.get(workflow_id)
+ except WorkflowNotFoundError:
+ raise HTTPException(status_code=404, detail="Workflow not found")
+
+ config = ApiDependencies.invoker.services.configuration
+ if config.multiuser and not current_user.is_admin and existing.user_id != current_user.user_id:
+ raise HTTPException(status_code=403, detail="Not authorized to update this workflow")
+
+ return ApiDependencies.invoker.services.workflow_records.update_is_public(
+ workflow_id=workflow_id, is_public=is_public
+ )
+
+
@workflows_router.get("/tags", operation_id="get_all_tags")
async def get_all_tags(
+ current_user: CurrentUserOrDefault,
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> list[str]:
"""Gets all unique tags from workflows"""
-
- return ApiDependencies.invoker.services.workflow_records.get_all_tags(categories=categories)
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
+
+ return ApiDependencies.invoker.services.workflow_records.get_all_tags(
+ categories=categories, user_id=user_id_filter, is_public=is_public
+ )
@workflows_router.get("/counts_by_tag", operation_id="get_counts_by_tag")
async def get_counts_by_tag(
+ current_user: CurrentUserOrDefault,
tags: list[str] = Query(description="The tags to get counts for"),
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories to include"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> dict[str, int]:
"""Counts workflows by tag"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ has_user_category = not categories or WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
return ApiDependencies.invoker.services.workflow_records.counts_by_tag(
- tags=tags, categories=categories, has_been_opened=has_been_opened
+ tags=tags, categories=categories, has_been_opened=has_been_opened, user_id=user_id_filter, is_public=is_public
)
@workflows_router.get("/counts_by_category", operation_id="counts_by_category")
async def counts_by_category(
+ current_user: CurrentUserOrDefault,
categories: list[WorkflowCategory] = Query(description="The categories to include"),
has_been_opened: Optional[bool] = Query(default=None, description="Whether to include/exclude recent workflows"),
+ is_public: Optional[bool] = Query(default=None, description="Filter by public/shared status"),
) -> dict[str, int]:
"""Counts workflows by category"""
+ config = ApiDependencies.invoker.services.configuration
+ user_id_filter: Optional[str] = None
+ if config.multiuser:
+ has_user_category = WorkflowCategory.User in categories
+ if has_user_category and is_public is not True:
+ user_id_filter = current_user.user_id
return ApiDependencies.invoker.services.workflow_records.counts_by_category(
- categories=categories, has_been_opened=has_been_opened
+ categories=categories, has_been_opened=has_been_opened, user_id=user_id_filter, is_public=is_public
)
diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py
index 645509f1dde..2478e8cdcae 100644
--- a/invokeai/app/services/shared/sqlite/sqlite_util.py
+++ b/invokeai/app/services/shared/sqlite/sqlite_util.py
@@ -30,6 +30,7 @@
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_25 import build_migration_25
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.sqlite_migrator_impl import SqliteMigrator
@@ -77,6 +78,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_25(app_config=config, logger=logger))
migrator.register_migration(build_migration_26(app_config=config, logger=logger))
migrator.register_migration(build_migration_27())
+ migrator.register_migration(build_migration_28())
migrator.run_migrations()
return db
diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
new file mode 100644
index 00000000000..0cbd683ab5e
--- /dev/null
+++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py
@@ -0,0 +1,45 @@
+"""Migration 28: Add per-user workflow isolation columns to workflow_library.
+
+This migration adds the database columns required for multiuser workflow isolation
+to the workflow_library table:
+- user_id: the owner of the workflow (defaults to 'system' for existing workflows)
+- is_public: whether the workflow is shared with all users
+"""
+
+import sqlite3
+
+from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
+
+
+class Migration28Callback:
+ """Migration to add user_id and is_public to the workflow_library table."""
+
+ def __call__(self, cursor: sqlite3.Cursor) -> None:
+ self._update_workflow_library_table(cursor)
+
+ def _update_workflow_library_table(self, cursor: sqlite3.Cursor) -> None:
+ """Add user_id and is_public columns to workflow_library table."""
+ cursor.execute("PRAGMA table_info(workflow_library);")
+ columns = [row[1] for row in cursor.fetchall()]
+
+ if "user_id" not in columns:
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN user_id TEXT DEFAULT 'system';")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_user_id ON workflow_library(user_id);")
+
+ if "is_public" not in columns:
+ cursor.execute("ALTER TABLE workflow_library ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflow_library_is_public ON workflow_library(is_public);")
+
+
+def build_migration_28() -> Migration:
+ """Builds the migration object for migrating from version 27 to version 28.
+
+ This migration adds per-user workflow isolation to the workflow_library table:
+ - user_id column: identifies the owner of each workflow
+ - is_public column: controls whether a workflow is shared with all users
+ """
+ return Migration(
+ from_version=27,
+ to_version=28,
+ callback=Migration28Callback(),
+ )
diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py
index 5ad66c59832..22721f81b0d 100644
--- a/invokeai/app/services/users/users_base.py
+++ b/invokeai/app/services/users/users_base.py
@@ -125,6 +125,15 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
"""
pass
+ @abstractmethod
+ def get_admin_email(self) -> str | None:
+ """Get the email address of the first active admin user.
+
+ Returns:
+ Email address of the first active admin, or None if no admin exists
+ """
+ pass
+
@abstractmethod
def count_admins(self) -> int:
"""Count active admin users.
diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py
index 506ae937f02..f9fa87dd382 100644
--- a/invokeai/app/services/users/users_default.py
+++ b/invokeai/app/services/users/users_default.py
@@ -250,6 +250,20 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
for row in rows
]
+ def get_admin_email(self) -> str | None:
+ """Get the email address of the first active admin user."""
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """
+ SELECT email FROM users
+ WHERE is_admin = TRUE AND is_active = TRUE
+ ORDER BY created_at ASC
+ LIMIT 1
+ """,
+ )
+ row = cursor.fetchone()
+ return row[0] if row else None
+
def count_admins(self) -> int:
"""Count active admin users."""
with self._db.transaction() as cursor:
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index d5cf319594b..8da1e97daf7 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -4,6 +4,7 @@
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection
from invokeai.app.services.workflow_records.workflow_records_common import (
+ WORKFLOW_LIBRARY_DEFAULT_USER_ID,
Workflow,
WorkflowCategory,
WorkflowRecordDTO,
@@ -22,7 +23,7 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
pass
@abstractmethod
- def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
+ def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
"""Creates a workflow."""
pass
@@ -47,6 +48,8 @@ def get_many(
query: Optional[str],
tags: Optional[list[str]],
has_been_opened: Optional[bool],
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
"""Gets many workflows."""
pass
@@ -56,6 +59,8 @@ def counts_by_category(
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided categories."""
pass
@@ -66,6 +71,8 @@ def counts_by_tag(
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
"""Gets a dictionary of counts for each of the provided tags."""
pass
@@ -79,6 +86,13 @@ def update_opened_at(self, workflow_id: str) -> None:
def get_all_tags(
self,
categories: Optional[list[WorkflowCategory]] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> list[str]:
"""Gets all unique tags from workflows."""
pass
+
+ @abstractmethod
+ def update_is_public(self, workflow_id: str, is_public: bool) -> WorkflowRecordDTO:
+ """Updates the is_public field of a workflow."""
+ pass
diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py
index e0cea37468d..9c505530c90 100644
--- a/invokeai/app/services/workflow_records/workflow_records_common.py
+++ b/invokeai/app/services/workflow_records/workflow_records_common.py
@@ -9,6 +9,9 @@
__workflow_meta_version__ = semver.Version.parse("1.0.0")
+WORKFLOW_LIBRARY_DEFAULT_USER_ID = "system"
+"""Default user_id for workflows created in single-user mode or migrated from pre-multiuser databases."""
+
class ExposedField(BaseModel):
nodeId: str
@@ -26,6 +29,7 @@ class WorkflowRecordOrderBy(str, Enum, metaclass=MetaEnum):
UpdatedAt = "updated_at"
OpenedAt = "opened_at"
Name = "name"
+ IsPublic = "is_public"
class WorkflowCategory(str, Enum, metaclass=MetaEnum):
@@ -100,6 +104,8 @@ class WorkflowRecordDTOBase(BaseModel):
opened_at: Optional[Union[datetime.datetime, str]] = Field(
default=None, description="The opened timestamp of the workflow."
)
+ user_id: str = Field(description="The id of the user who owns this workflow.")
+ is_public: bool = Field(description="Whether this workflow is shared with all users.")
class WorkflowRecordDTO(WorkflowRecordDTOBase):
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index 0f72f7cd92c..0e6dfe1b700 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -7,6 +7,7 @@
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase
from invokeai.app.services.workflow_records.workflow_records_common import (
+ WORKFLOW_LIBRARY_DEFAULT_USER_ID,
Workflow,
WorkflowCategory,
WorkflowNotFoundError,
@@ -36,7 +37,7 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
with self._db.transaction() as cursor:
cursor.execute(
"""--sql
- SELECT workflow_id, workflow, name, created_at, updated_at, opened_at
+ SELECT workflow_id, workflow, name, created_at, updated_at, opened_at, user_id, is_public
FROM workflow_library
WHERE workflow_id = ?;
""",
@@ -47,7 +48,7 @@ def get(self, workflow_id: str) -> WorkflowRecordDTO:
raise WorkflowNotFoundError(f"Workflow with id {workflow_id} not found")
return WorkflowRecordDTO.from_dict(dict(row))
- def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
+ def create(self, workflow: WorkflowWithoutID, user_id: str = WORKFLOW_LIBRARY_DEFAULT_USER_ID) -> WorkflowRecordDTO:
if workflow.meta.category is WorkflowCategory.Default:
raise ValueError("Default workflows cannot be created via this method")
@@ -57,11 +58,12 @@ def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO:
"""--sql
INSERT OR IGNORE INTO workflow_library (
workflow_id,
- workflow
+ workflow,
+ user_id
)
- VALUES (?, ?);
+ VALUES (?, ?, ?);
""",
- (workflow_with_id.id, workflow_with_id.model_dump_json()),
+ (workflow_with_id.id, workflow_with_id.model_dump_json(), user_id),
)
return self.get(workflow_with_id.id)
@@ -94,6 +96,31 @@ def delete(self, workflow_id: str) -> None:
)
return None
+ def update_is_public(self, workflow_id: str, is_public: bool) -> WorkflowRecordDTO:
+ """Updates the is_public field of a workflow and manages the 'shared' tag automatically."""
+ record = self.get(workflow_id)
+ workflow = record.workflow
+
+ # Manage "shared" tag: add when public, remove when private
+ tags_list = [t.strip() for t in workflow.tags.split(",") if t.strip()] if workflow.tags else []
+ if is_public and "shared" not in tags_list:
+ tags_list.append("shared")
+ elif not is_public and "shared" in tags_list:
+ tags_list.remove("shared")
+ updated_tags = ", ".join(tags_list)
+ updated_workflow = workflow.model_copy(update={"tags": updated_tags})
+
+ with self._db.transaction() as cursor:
+ cursor.execute(
+ """--sql
+ UPDATE workflow_library
+ SET workflow = ?, is_public = ?
+ WHERE workflow_id = ? AND category = 'user';
+ """,
+ (updated_workflow.model_dump_json(), is_public, workflow_id),
+ )
+ return self.get(workflow_id)
+
def get_many(
self,
order_by: WorkflowRecordOrderBy,
@@ -104,6 +131,8 @@ def get_many(
query: Optional[str] = None,
tags: Optional[list[str]] = None,
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> PaginatedResults[WorkflowRecordListItemDTO]:
with self._db.transaction() as cursor:
# sanitize!
@@ -122,7 +151,9 @@ def get_many(
created_at,
updated_at,
opened_at,
- tags
+ tags,
+ user_id,
+ is_public
FROM workflow_library
"""
count_query = "SELECT COUNT(*) FROM workflow_library"
@@ -177,6 +208,15 @@ def get_many(
conditions.append(query_condition)
params.extend([wildcard_query, wildcard_query, wildcard_query])
+ if user_id is not None:
+ conditions.append("user_id = ?")
+ params.append(user_id)
+
+ if is_public is True:
+ conditions.append("is_public = TRUE")
+ elif is_public is False:
+ conditions.append("is_public = FALSE")
+
if conditions:
# If there are conditions, add a WHERE clause and then join the conditions
main_query += " WHERE "
@@ -226,6 +266,8 @@ def counts_by_tag(
tags: list[str],
categories: Optional[list[WorkflowCategory]] = None,
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
if not tags:
return {}
@@ -248,6 +290,15 @@ def counts_by_tag(
elif has_been_opened is False:
base_conditions.append("opened_at IS NULL")
+ if user_id is not None:
+ base_conditions.append("user_id = ?")
+ base_params.append(user_id)
+
+ if is_public is True:
+ base_conditions.append("is_public = TRUE")
+ elif is_public is False:
+ base_conditions.append("is_public = FALSE")
+
# For each tag to count, run a separate query
for tag in tags:
# Start with the base conditions
@@ -277,6 +328,8 @@ def counts_by_category(
self,
categories: list[WorkflowCategory],
has_been_opened: Optional[bool] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> dict[str, int]:
with self._db.transaction() as cursor:
result: dict[str, int] = {}
@@ -296,6 +349,15 @@ def counts_by_category(
elif has_been_opened is False:
base_conditions.append("opened_at IS NULL")
+ if user_id is not None:
+ base_conditions.append("user_id = ?")
+ base_params.append(user_id)
+
+ if is_public is True:
+ base_conditions.append("is_public = TRUE")
+ elif is_public is False:
+ base_conditions.append("is_public = FALSE")
+
# For each category to count, run a separate query
for category in categories:
# Start with the base conditions
@@ -335,6 +397,8 @@ def update_opened_at(self, workflow_id: str) -> None:
def get_all_tags(
self,
categories: Optional[list[WorkflowCategory]] = None,
+ user_id: Optional[str] = None,
+ is_public: Optional[bool] = None,
) -> list[str]:
with self._db.transaction() as cursor:
conditions: list[str] = []
@@ -349,6 +413,15 @@ def get_all_tags(
conditions.append(f"category IN ({placeholders})")
params.extend([category.value for category in categories])
+ if user_id is not None:
+ conditions.append("user_id = ?")
+ params.append(user_id)
+
+ if is_public is True:
+ conditions.append("is_public = TRUE")
+ elif is_public is False:
+ conditions.append("is_public = FALSE")
+
stmt = """--sql
SELECT DISTINCT tags
FROM workflow_library
diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json
index af8476528d6..19e5a3a68e9 100644
--- a/invokeai/frontend/web/openapi.json
+++ b/invokeai/frontend/web/openapi.json
@@ -6463,6 +6463,23 @@
"title": "Has Been Opened"
},
"description": "Whether to include/exclude recent workflows"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -6655,6 +6672,23 @@
"title": "Categories"
},
"description": "The categories to include"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -6744,6 +6778,23 @@
"title": "Has Been Opened"
},
"description": "Whether to include/exclude recent workflows"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -6812,6 +6863,23 @@
"title": "Has Been Opened"
},
"description": "Whether to include/exclude recent workflows"
+ },
+ {
+ "name": "is_public",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Is Public"
+ },
+ "description": "Filter by public/shared status"
}
],
"responses": {
@@ -7352,6 +7420,67 @@
}
}
}
+ },
+ "/api/v1/workflows/i/{workflow_id}/is_public": {
+ "patch": {
+ "tags": ["workflows"],
+ "summary": "Update Workflow Is Public",
+ "description": "Updates whether a workflow is shared publicly",
+ "operationId": "update_workflow_is_public",
+ "parameters": [
+ {
+ "name": "workflow_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Workflow Id"
+ },
+ "description": "The workflow to update"
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether the workflow should be shared publicly"
+ }
+ },
+ "type": "object",
+ "required": ["is_public"],
+ "title": "Body_update_workflow_is_public"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WorkflowRecordDTO"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"components": {
@@ -59137,10 +59266,20 @@
"workflow": {
"$ref": "#/components/schemas/Workflow",
"description": "The workflow."
+ },
+ "user_id": {
+ "type": "string",
+ "title": "User Id",
+ "description": "The id of the user who owns this workflow."
+ },
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether this workflow is shared with all users."
}
},
"type": "object",
- "required": ["workflow_id", "name", "created_at", "updated_at", "workflow"],
+ "required": ["workflow_id", "name", "created_at", "updated_at", "workflow", "user_id", "is_public"],
"title": "WorkflowRecordDTO"
},
"WorkflowRecordListItemWithThumbnailDTO": {
@@ -59222,15 +59361,35 @@
],
"title": "Thumbnail Url",
"description": "The URL of the workflow thumbnail."
+ },
+ "user_id": {
+ "type": "string",
+ "title": "User Id",
+ "description": "The id of the user who owns this workflow."
+ },
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether this workflow is shared with all users."
}
},
"type": "object",
- "required": ["workflow_id", "name", "created_at", "updated_at", "description", "category", "tags"],
+ "required": [
+ "workflow_id",
+ "name",
+ "created_at",
+ "updated_at",
+ "description",
+ "category",
+ "tags",
+ "user_id",
+ "is_public"
+ ],
"title": "WorkflowRecordListItemWithThumbnailDTO"
},
"WorkflowRecordOrderBy": {
"type": "string",
- "enum": ["created_at", "updated_at", "opened_at", "name"],
+ "enum": ["created_at", "updated_at", "opened_at", "name", "is_public"],
"title": "WorkflowRecordOrderBy",
"description": "The order by options for workflow records"
},
@@ -59303,10 +59462,20 @@
],
"title": "Thumbnail Url",
"description": "The URL of the workflow thumbnail."
+ },
+ "user_id": {
+ "type": "string",
+ "title": "User Id",
+ "description": "The id of the user who owns this workflow."
+ },
+ "is_public": {
+ "type": "boolean",
+ "title": "Is Public",
+ "description": "Whether this workflow is shared with all users."
}
},
"type": "object",
- "required": ["workflow_id", "name", "created_at", "updated_at", "workflow"],
+ "required": ["workflow_id", "name", "created_at", "updated_at", "workflow", "user_id", "is_public"],
"title": "WorkflowRecordWithThumbnailDTO"
},
"WorkflowWithoutID": {
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 617c4341574..41dda9f8ee6 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -1113,7 +1113,9 @@
"name": "Name",
"modelPickerFallbackNoModelsInstalled": "No models installed.",
"modelPickerFallbackNoModelsInstalled2": "Visit the Model Manager to install models.",
+ "modelPickerFallbackNoModelsInstalledNonAdmin": "No models installed. Ask your InvokeAI administrator () to install some models.",
"noModelsInstalledDesc1": "Install models with the",
+ "noModelsInstalledAskAdmin": "Ask your administrator to install some.",
"noModelSelected": "No Model Selected",
"noMatchingModels": "No matching models",
"noModelsInstalled": "No models installed",
@@ -2211,6 +2213,8 @@
"tags": "Tags",
"yourWorkflows": "Your Workflows",
"recentlyOpened": "Recently Opened",
+ "sharedWorkflows": "Shared Workflows",
+ "shareWorkflow": "Shared workflow",
"noRecentWorkflows": "No Recent Workflows",
"private": "Private",
"shared": "Shared",
@@ -2908,6 +2912,7 @@
"tileOverlap": "Tile Overlap",
"postProcessingMissingModelWarning": "Visit the Model Manager to install a post-processing (image to image) model.",
"missingModelsWarning": "Visit the Model Manager to install the required models:",
+ "missingModelsWarningNonAdmin": "Ask your InvokeAI administrator () to install the required models:",
"mainModelDesc": "Main model (SD1.5 or SDXL architecture)",
"tileControlNetModelDesc": "Tile ControlNet model for the chosen main model architecture",
"upscaleModelDesc": "Upscale (image to image) model",
@@ -3016,6 +3021,7 @@
},
"workflows": {
"description": "Workflows are reusable templates that automate image generation tasks, allowing you to quickly perform complex operations and get consistent results.",
+ "descriptionMultiuser": "Workflows are reusable templates that automate image generation tasks, allowing you to quickly perform complex operations and get consistent results. You may share your workflows with other users of the system by selecting 'Shared workflow' when you create or edit it.",
"learnMoreLink": "Learn more about creating workflows",
"browseTemplates": {
"title": "Browse Workflow Templates",
@@ -3094,9 +3100,11 @@
"toGetStartedLocal": "To get started, make sure to download or import models needed to run Invoke. Then, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.",
"toGetStarted": "To get started, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.",
"toGetStartedWorkflow": "To get started, fill in the fields on the left and press Invoke to generate your image. Want to explore more workflows? Click the folder icon next to the workflow title to see a list of other templates you can try.",
+ "toGetStartedNonAdmin": "To get started, ask your InvokeAI administrator () to install the AI models needed to run Invoke. Then, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.",
"gettingStartedSeries": "Want more guidance? Check out our Getting Started Series for tips on unlocking the full potential of the Invoke Studio.",
"lowVRAMMode": "For best performance, follow our Low VRAM guide.",
- "noModelsInstalled": "It looks like you don't have any models installed! You can download a starter model bundle or import models."
+ "noModelsInstalled": "It looks like you don't have any models installed! You can download a starter model bundle or import models.",
+ "noModelsInstalledAskAdmin": "Ask your administrator to install some."
},
"whatsNew": {
"whatsNewInInvoke": "What's New in Invoke",
diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx
index 1649a14c511..93e5ba111c4 100644
--- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx
+++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/NoContentForViewer.tsx
@@ -1,7 +1,9 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Alert, AlertDescription, AlertIcon, Button, Divider, Flex, Link, Spinner, Text } from '@invoke-ai/ui-library';
+import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvokeLogoIcon } from 'common/components/InvokeLogoIcon';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { LOADING_SYMBOL, useHasImages } from 'features/gallery/hooks/useHasImages';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import { navigationApi } from 'features/ui/layouts/navigation-api';
@@ -9,16 +11,26 @@ import type { PropsWithChildren } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiArrowSquareOutBold, PiImageBold } from 'react-icons/pi';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useMainModels } from 'services/api/hooks/modelsByType';
export const NoContentForViewer = memo(() => {
const hasImages = useHasImages();
const [mainModels, { data }] = useMainModels();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
const { t } = useTranslation();
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
+ const adminEmail = setupStatus?.admin_email ?? null;
+
+ const modelsLoaded = data !== undefined;
+ const hasModels = mainModels.length > 0;
+
const showStarterBundles = useMemo(() => {
- return data && mainModels.length === 0;
- }, [mainModels.length, data]);
+ return modelsLoaded && !hasModels && isAdmin;
+ }, [modelsLoaded, hasModels, isAdmin]);
if (hasImages === LOADING_SYMBOL) {
// Blank bg w/ a spinner. The new user experience components below have an invoke logo, but it's not centered.
@@ -36,10 +48,18 @@ export const NoContentForViewer = memo(() => {
-
- {showStarterBundles && }
-
-
+ {isAdmin ? (
+ // Admin / single-user mode
+ <>
+ {modelsLoaded && hasModels ? : }
+ {showStarterBundles && }
+
+
+ >
+ ) : (
+ // Non-admin user in multiuser mode
+ <>{modelsLoaded && hasModels ? : }>
+ )}
);
@@ -89,6 +109,32 @@ const GetStartedLocal = () => {
);
};
+const GetStartedWithModels = () => {
+ return (
+
+
+
+ );
+};
+
+const GetStartedNonAdmin = ({ adminEmail }: { adminEmail: string | null }) => {
+ const AdminEmailLink = adminEmail ? (
+
+ {adminEmail}
+
+ ) : (
+
+ your administrator
+
+ );
+
+ return (
+
+
+
+ );
+};
+
const StarterBundlesCallout = () => {
const handleClickDownloadStarterModels = useCallback(() => {
navigationApi.switchToTab('models');
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
index d1774f9ded0..9b76fbbde67 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx
@@ -1,10 +1,11 @@
import { Button, Text, useToast } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
-import { selectIsAuthenticated } from 'features/auth/store/authSlice';
+import { selectCurrentUser, selectIsAuthenticated } from 'features/auth/store/authSlice';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useMainModels } from 'services/api/hooks/modelsByType';
const TOAST_ID = 'starterModels';
@@ -15,6 +16,11 @@ export const useStarterModelsToast = () => {
const [mainModels, { data }] = useMainModels();
const toast = useToast();
const isAuthenticated = useAppSelector(selectIsAuthenticated);
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
useEffect(() => {
// Only show the toast if the user is authenticated
@@ -33,17 +39,17 @@ export const useStarterModelsToast = () => {
toast({
id: TOAST_ID,
title: t('modelManager.noModelsInstalled'),
- description: ,
+ description: isAdmin ? : ,
status: 'info',
isClosable: true,
duration: null,
onCloseComplete: () => setDidToast(true),
});
}
- }, [data, didToast, isAuthenticated, mainModels.length, t, toast]);
+ }, [data, didToast, isAuthenticated, isAdmin, mainModels.length, t, toast]);
};
-const ToastDescription = () => {
+const AdminToastDescription = () => {
const { t } = useTranslation();
const toast = useToast();
@@ -62,3 +68,9 @@ const ToastDescription = () => {
);
};
+
+const NonAdminToastDescription = () => {
+ const { t } = useTranslation();
+
+ return {t('modelManager.noModelsInstalledAskAdmin')};
+};
diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
index f6e1a18f6fd..60200c8801f 100644
--- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
+++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx
@@ -37,7 +37,7 @@ export const ModelManager = memo(() => {
{t('common.modelManager')}
-
+ {canManageModels && }
{!!selectedModelKey && canManageModels && (
} onClick={handleClickAddModel}>
{t('modelManager.addModels')}
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx
index 91c6c1dae38..fe4b889f540 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx
@@ -1,5 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
+import { useIsCurrentWorkflowOwner } from 'features/workflowLibrary/hooks/useIsCurrentWorkflowOwner';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,6 +9,7 @@ import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
+ const isCurrentWorkflowOwner = useIsCurrentWorkflowOwner();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
return (
@@ -15,7 +17,7 @@ const SaveWorkflowButton = () => {
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={}
- isDisabled={!doesWorkflowHaveUnsavedChanges}
+ isDisabled={!doesWorkflowHaveUnsavedChanges || !isCurrentWorkflowOwner}
onClick={saveOrSaveAsWorkflow}
pointerEvents="auto"
/>
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx
index 39a93e4a382..779d6f018ee 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/SaveWorkflowButton.tsx
@@ -1,4 +1,6 @@
import { IconButton } from '@invoke-ai/ui-library';
+import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
+import { useIsCurrentWorkflowOwner } from 'features/workflowLibrary/hooks/useIsCurrentWorkflowOwner';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -7,12 +9,15 @@ import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
+ const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
+ const isCurrentWorkflowOwner = useIsCurrentWorkflowOwner();
return (
}
+ isDisabled={!doesWorkflowHaveUnsavedChanges || !isCurrentWorkflowOwner}
onClick={saveOrSaveAsWorkflow}
pointerEvents="auto"
variant="ghost"
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx
index c1094abf86d..11d27335352 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx
@@ -1,8 +1,19 @@
import type { FormControlProps } from '@invoke-ai/ui-library';
-import { Box, Flex, FormControl, FormControlGroup, FormLabel, Image, Input, Textarea } from '@invoke-ai/ui-library';
+import {
+ Box,
+ Checkbox,
+ Flex,
+ FormControl,
+ FormControlGroup,
+ FormLabel,
+ Image,
+ Input,
+ Textarea,
+} from '@invoke-ai/ui-library';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import {
workflowAuthorChanged,
workflowContactChanged,
@@ -25,7 +36,8 @@ import {
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
+import { useGetWorkflowQuery, useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
import { WorkflowThumbnailEditor } from './WorkflowThumbnail/WorkflowThumbnailEditor';
@@ -95,6 +107,7 @@ const WorkflowGeneralTab = () => {
{t('nodes.workflowName')}
+
{t('nodes.workflowVersion')}
@@ -187,3 +200,40 @@ const Thumbnail = ({ id }: { id?: string | null }) => {
// This is a default workflow and it does not have a thumbnail set. Users may not edit the thumbnail.
return null;
};
+
+const ShareWorkflowCheckbox = ({ id }: { id?: string | null }) => {
+ const { t } = useTranslation();
+ const currentUser = useAppSelector(selectCurrentUser);
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const { data } = useGetWorkflowQuery(id ?? skipToken);
+ const [updateIsPublic, { isLoading }] = useUpdateWorkflowIsPublicMutation();
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ if (!id) {
+ return;
+ }
+ updateIsPublic({ workflow_id: id, is_public: e.target.checked });
+ },
+ [id, updateIsPublic]
+ );
+
+ // Only show for saved user workflows in multiuser mode when the current user is the owner or admin
+ if (!data || !id || data.workflow.meta.category !== 'user') {
+ return null;
+ }
+ if (setupStatus?.multiuser_enabled) {
+ const isOwner = currentUser !== null && data.user_id === currentUser.user_id;
+ const isAdmin = currentUser?.is_admin ?? false;
+ if (!isOwner && !isAdmin) {
+ return null;
+ }
+ }
+
+ return (
+
+
+ {t('workflows.shareWorkflow')}
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
index 73b046c83a9..501b8365db5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx
@@ -41,6 +41,7 @@ export const WorkflowLibrarySideNav = () => {
{t('workflows.recentlyOpened')}
+ {t('workflows.sharedWorkflows')}
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
index 79dff535b05..e6605d2076a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -32,6 +32,8 @@ const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => {
return ['user', 'default'];
case 'yours':
return ['user'];
+ case 'shared':
+ return ['user'];
default:
assert>(false);
}
@@ -44,6 +46,13 @@ const getHasBeenOpened = (view: WorkflowLibraryView): boolean | undefined => {
return undefined;
};
+const getIsPublic = (view: WorkflowLibraryView): boolean | undefined => {
+ if (view === 'shared') {
+ return true;
+ }
+ return undefined;
+};
+
const useInfiniteQueryAry = () => {
const orderBy = useAppSelector(selectWorkflowLibraryOrderBy);
const direction = useAppSelector(selectWorkflowLibraryDirection);
@@ -62,6 +71,7 @@ const useInfiniteQueryAry = () => {
query: debouncedSearchTerm,
tags: view === 'defaults' || view === 'yours' ? selectedTags : [],
has_been_opened: getHasBeenOpened(view),
+ is_public: getIsPublic(view),
} satisfies Parameters[0];
}, [orderBy, direction, view, debouncedSearchTerm, selectedTags]);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
index a1767765c93..a184f04039a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
@@ -1,13 +1,15 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
-import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
+import { Badge, Flex, Icon, Image, Spacer, Switch, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectWorkflowId } from 'features/nodes/store/selectors';
import { workflowModeChanged } from 'features/nodes/store/workflowLibrarySlice';
import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
-import { memo, useCallback, useMemo } from 'react';
+import { type ChangeEvent, memo, type MouseEvent, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImage } from 'react-icons/pi';
+import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
import { DeleteWorkflow } from './WorkflowLibraryListItemActions/DeleteWorkflow';
@@ -33,12 +35,21 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
const { t } = useTranslation();
const dispatch = useAppDispatch();
const workflowId = useAppSelector(selectWorkflowId);
+ const currentUser = useAppSelector(selectCurrentUser);
const loadWorkflowWithDialog = useLoadWorkflowWithDialog();
const isActive = useMemo(() => {
return workflowId === workflow.workflow_id;
}, [workflowId, workflow.workflow_id]);
+ const isOwner = useMemo(() => {
+ return currentUser !== null && workflow.user_id === currentUser.user_id;
+ }, [currentUser, workflow.user_id]);
+
+ const canEditOrDelete = useMemo(() => {
+ return isOwner || (currentUser?.is_admin ?? false);
+ }, [isOwner, currentUser]);
+
const tags = useMemo(() => {
if (!workflow.tags) {
return [];
@@ -102,6 +113,18 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
{t('workflows.opened')}
)}
+ {workflow.is_public && workflow.category !== 'default' && (
+
+ {t('workflows.shared')}
+
+ )}
{workflow.category === 'default' && (
)}
+ {isOwner && }
{workflow.category === 'default' && }
{workflow.category !== 'default' && (
<>
-
+ {canEditOrDelete && }
-
+ {canEditOrDelete && }
>
)}
@@ -152,6 +176,35 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi
});
WorkflowListItem.displayName = 'WorkflowListItem';
+const ShareWorkflowToggle = memo(({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
+ const { t } = useTranslation();
+ const [updateIsPublic, { isLoading }] = useUpdateWorkflowIsPublicMutation();
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ e.stopPropagation();
+ updateIsPublic({ workflow_id: workflow.workflow_id, is_public: e.target.checked });
+ },
+ [updateIsPublic, workflow.workflow_id]
+ );
+
+ const handleClick = useCallback((e: MouseEvent) => {
+ e.stopPropagation();
+ }, []);
+
+ return (
+
+
+
+ {t('workflows.shared')}
+
+
+
+
+ );
+});
+ShareWorkflowToggle.displayName = 'ShareWorkflowToggle';
+
const UserThumbnailFallback = memo(() => {
return (
;
const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success;
@@ -32,6 +32,7 @@ export const WorkflowSortControl = () => {
created_at: t('workflows.created'),
updated_at: t('workflows.updated'),
name: t('workflows.name'),
+ is_public: t('workflows.shared'),
}),
[t]
);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
index ee85a03c18f..1d5d8554aeb 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts
@@ -11,7 +11,7 @@ import {
} from 'services/api/types';
import z from 'zod';
-const zWorkflowLibraryView = z.enum(['recent', 'yours', 'defaults']);
+const zWorkflowLibraryView = z.enum(['recent', 'yours', 'shared', 'defaults']);
export type WorkflowLibraryView = z.infer;
const zWorkflowLibraryState = z.object({
@@ -55,6 +55,9 @@ const slice = createSlice({
if (action.payload === 'recent') {
state.orderBy = 'opened_at';
state.direction = 'DESC';
+ } else if (action.payload === 'shared') {
+ state.orderBy = 'name';
+ state.direction = 'ASC';
}
},
workflowLibraryTagToggled: (state, action: PayloadAction) => {
@@ -121,5 +124,11 @@ export const WORKFLOW_LIBRARY_TAG_CATEGORIES: WorkflowTagCategory[] = [
];
export const WORKFLOW_LIBRARY_TAGS = WORKFLOW_LIBRARY_TAG_CATEGORIES.flatMap(({ tags }) => tags);
-type WorkflowSortOption = 'opened_at' | 'created_at' | 'updated_at' | 'name';
-export const WORKFLOW_LIBRARY_SORT_OPTIONS: WorkflowSortOption[] = ['opened_at', 'created_at', 'updated_at', 'name'];
+type WorkflowSortOption = 'opened_at' | 'created_at' | 'updated_at' | 'name' | 'is_public';
+export const WORKFLOW_LIBRARY_SORT_OPTIONS: WorkflowSortOption[] = [
+ 'opened_at',
+ 'created_at',
+ 'updated_at',
+ 'name',
+ 'is_public',
+];
diff --git a/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx b/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx
index c5397791b84..f40f1d29c1c 100644
--- a/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx
+++ b/invokeai/frontend/web/src/features/parameters/components/ModelPicker.tsx
@@ -3,6 +3,7 @@ import {
Button,
Flex,
Icon,
+ Link,
Popover,
PopoverArrow,
PopoverBody,
@@ -20,6 +21,7 @@ import { buildGroup, getRegex, isGroup, Picker, usePickerContext } from 'common/
import { useDisclosure } from 'common/hooks/useBoolean';
import { typedMemo } from 'common/util/typedMemo';
import { uniq } from 'es-toolkit/compat';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectLoRAsSlice } from 'features/controlLayers/store/lorasSlice';
import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice';
import { MODEL_BASE_TO_COLOR, MODEL_BASE_TO_LONG_NAME, MODEL_BASE_TO_SHORT_NAME } from 'features/modelManagerV2/models';
@@ -32,6 +34,7 @@ import { filesize } from 'filesize';
import { memo, useCallback, useMemo, useRef } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { PiCaretDownBold, PiLinkSimple } from 'react-icons/pi';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useGetRelatedModelIdsBatchQuery } from 'services/api/endpoints/modelRelationships';
import type { AnyModelConfig } from 'services/api/types';
@@ -82,6 +85,32 @@ const components = {
const NoOptionsFallback = memo(({ noOptionsText }: { noOptionsText?: string }) => {
const { t } = useTranslation();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
+ const adminEmail = setupStatus?.admin_email ?? null;
+
+ if (!isAdmin) {
+ const AdminEmailLink = adminEmail ? (
+
+ {adminEmail}
+
+ ) : (
+
+ your administrator
+
+ );
+
+ return (
+
+
+
+
+
+ );
+ }
return (
diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx
index 7d0a7ee2def..ff19e7ebb31 100644
--- a/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx
+++ b/invokeai/frontend/web/src/features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleWarning.tsx
@@ -1,5 +1,6 @@
-import { Button, Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
+import { Button, Flex, Link, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
import { selectModel } from 'features/controlLayers/store/paramsSlice';
import { setInstallModelsTabByName } from 'features/modelManagerV2/store/installModelsStore';
import {
@@ -10,6 +11,7 @@ import {
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { useCallback, useEffect, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { useControlNetModels } from 'services/api/hooks/modelsByType';
export const UpscaleWarning = () => {
@@ -19,6 +21,12 @@ export const UpscaleWarning = () => {
const tileControlnetModel = useAppSelector(selectTileControlNetModel);
const dispatch = useAppDispatch();
const [modelConfigs, { isLoading }] = useControlNetModels();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const user = useAppSelector(selectCurrentUser);
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
+ const isAdmin = !isMultiuser || (user?.is_admin ?? false);
+ const adminEmail = setupStatus?.admin_email ?? null;
useEffect(() => {
const validModel = modelConfigs.find((cnetModel) => {
@@ -59,19 +67,33 @@ export const UpscaleWarning = () => {
return null;
}
+ const AdminEmailLink = adminEmail ? (
+
+ {adminEmail}
+
+ ) : (
+
+ your administrator
+
+ );
+
return (
{!isBaseModelCompatible && {t('upscaling.incompatibleBaseModelDesc')}}
{warnings.length > 0 && (
-
- ),
- }}
- />
+ {isAdmin ? (
+
+ ),
+ }}
+ />
+ ) : (
+
+ )}
)}
{warnings.length > 0 && (
diff --git a/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx
index d432f3193ef..b0d087528ad 100644
--- a/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx
+++ b/invokeai/frontend/web/src/features/ui/layouts/WorkflowsLaunchpadPanel.tsx
@@ -6,6 +6,7 @@ import { memo, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { PiFilePlusBold, PiFolderOpenBold, PiUploadBold } from 'react-icons/pi';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
import { LaunchpadButton } from './LaunchpadButton';
import { LaunchpadContainer } from './LaunchpadContainer';
@@ -14,6 +15,9 @@ export const WorkflowsLaunchpadPanel = memo(() => {
const { t } = useTranslation();
const workflowLibraryModal = useWorkflowLibraryModal();
const newWorkflow = useNewWorkflow();
+ const { data: setupStatus } = useGetSetupStatusQuery();
+
+ const isMultiuser = setupStatus?.multiuser_enabled ?? false;
const handleBrowseTemplates = useCallback(() => {
workflowLibraryModal.open();
@@ -45,11 +49,15 @@ export const WorkflowsLaunchpadPanel = memo(() => {
multiple: false,
});
+ const descriptionKey = isMultiuser
+ ? 'ui.launchpad.workflows.descriptionMultiuser'
+ : 'ui.launchpad.workflows.description';
+
return (
{/* Description */}
- {t('ui.launchpad.workflows.description')}
+ {t(descriptionKey)}
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
index 72ca9c309b3..e29ca82fa2b 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog.tsx
@@ -5,6 +5,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
Button,
+ Checkbox,
Flex,
FormControl,
FormLabel,
@@ -19,6 +20,7 @@ import { t } from 'i18next';
import { atom, computed } from 'nanostores';
import type { ChangeEvent, RefObject } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
+import { useUpdateWorkflowIsPublicMutation } from 'services/api/endpoints/workflows';
import { assert } from 'tsafe';
/**
@@ -87,8 +89,10 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
}
return '';
});
+ const [isPublic, setIsPublic] = useState(false);
const { createNewWorkflow } = useCreateLibraryWorkflow();
+ const [updateIsPublic] = useUpdateWorkflowIsPublicMutation();
const inputRef = useRef(null);
@@ -96,6 +100,10 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
setName(e.target.value);
}, []);
+ const onChangeIsPublic = useCallback((e: ChangeEvent) => {
+ setIsPublic(e.target.checked);
+ }, []);
+
const onClose = useCallback(() => {
$workflowToSave.set(null);
}, []);
@@ -110,10 +118,19 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
await createNewWorkflow({
workflow,
- onSuccess: onClose,
+ onSuccess: async (workflowId?: string) => {
+ if (isPublic && workflowId) {
+ try {
+ await updateIsPublic({ workflow_id: workflowId, is_public: true }).unwrap();
+ } catch {
+ // Sharing failed silently - workflow was saved, just not shared
+ }
+ }
+ onClose();
+ },
onError: onClose,
});
- }, [workflow, name, createNewWorkflow, onClose]);
+ }, [workflow, name, isPublic, createNewWorkflow, updateIsPublic, onClose]);
return (
@@ -126,6 +143,10 @@ const Content = memo(({ workflow, cancelRef }: { workflow: WorkflowV3; cancelRef
{t('workflows.workflowName')}
+
+
+ {t('workflows.shareWorkflow')}
+
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
index 6f5acc431ed..e683cfdbefd 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx
@@ -1,5 +1,6 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useDoesWorkflowHaveUnsavedChanges } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
+import { useIsCurrentWorkflowOwner } from 'features/workflowLibrary/hooks/useIsCurrentWorkflowOwner';
import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,11 +10,12 @@ const SaveWorkflowMenuItem = () => {
const { t } = useTranslation();
const saveOrSaveAsWorkflow = useSaveOrSaveAsWorkflow();
const doesWorkflowHaveUnsavedChanges = useDoesWorkflowHaveUnsavedChanges();
+ const isCurrentWorkflowOwner = useIsCurrentWorkflowOwner();
return (
}
onClick={saveOrSaveAsWorkflow}
>
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
index 543283c779c..37fe48726e0 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useCreateNewWorkflow.ts
@@ -29,7 +29,7 @@ export const isDraftWorkflow = (workflow: WorkflowV3): workflow is DraftWorkflow
type CreateLibraryWorkflowArg = {
workflow: DraftWorkflow;
- onSuccess?: () => void;
+ onSuccess?: (workflowId?: string) => void;
onError?: () => void;
};
@@ -70,7 +70,7 @@ export const useCreateLibraryWorkflow = (): CreateLibraryWorkflowReturn => {
// When a workflow is saved, the form field initial values are updated to the current form field values
dispatch(formFieldInitialValuesChanged({ formFieldInitialValues: getFormFieldInitialValues() }));
updateOpenedAt({ workflow_id: id });
- onSuccess?.();
+ onSuccess?.(id);
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),
status: 'success',
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts
new file mode 100644
index 00000000000..5183c9050b7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useIsCurrentWorkflowOwner.ts
@@ -0,0 +1,48 @@
+import { skipToken } from '@reduxjs/toolkit/query';
+import { useAppSelector } from 'app/store/storeHooks';
+import { selectCurrentUser } from 'features/auth/store/authSlice';
+import { selectWorkflowId } from 'features/nodes/store/selectors';
+import { useMemo } from 'react';
+import { useGetSetupStatusQuery } from 'services/api/endpoints/auth';
+import { useGetWorkflowQuery } from 'services/api/endpoints/workflows';
+
+/**
+ * Returns true if the current user can save the currently-loaded workflow directly (not as a copy).
+ *
+ * In single-user mode, this always returns true.
+ * In multiuser mode, returns true when:
+ * - The workflow has no ID (new, unsaved workflow — will open Save As)
+ * - The current user is the owner of the workflow
+ * - The current user is an admin
+ */
+export const useIsCurrentWorkflowOwner = (): boolean => {
+ const workflowId = useAppSelector(selectWorkflowId);
+ const currentUser = useAppSelector(selectCurrentUser);
+ const { data: setupStatus } = useGetSetupStatusQuery();
+ const { data: workflowData } = useGetWorkflowQuery(workflowId ?? skipToken);
+
+ return useMemo(() => {
+ // In single-user mode there is no concept of ownership, so saving is always allowed.
+ if (!setupStatus?.multiuser_enabled) {
+ return true;
+ }
+
+ // No authenticated user — be permissive.
+ if (!currentUser) {
+ return true;
+ }
+
+ // No workflow ID means this is a new/unsaved workflow. Clicking "Save" will open the
+ // Save As dialog, so we should not block it.
+ if (!workflowId) {
+ return true;
+ }
+
+ // API data not yet available — be permissive to avoid incorrect disabling during loading.
+ if (!workflowData) {
+ return true;
+ }
+
+ return workflowData.user_id === currentUser.user_id || currentUser.is_admin;
+ }, [setupStatus?.multiuser_enabled, workflowId, workflowData, currentUser]);
+};
diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts
index c7a8a8b1ffc..8dc30fb332c 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts
@@ -33,6 +33,7 @@ type LogoutResponse = {
type SetupStatusResponse = {
setup_required: boolean;
multiuser_enabled: boolean;
+ admin_email: string | null;
};
export type UserDTO = components['schemas']['UserDTO'];
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index f58d3281a26..176546c90fd 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -157,6 +157,21 @@ export const workflowsApi = api.injectEndpoints({
}),
invalidatesTags: (result, error, workflow_id) => [{ type: 'Workflow', id: workflow_id }],
}),
+ updateWorkflowIsPublic: build.mutation<
+ paths['/api/v1/workflows/i/{workflow_id}/is_public']['patch']['responses']['200']['content']['application/json'],
+ { workflow_id: string; is_public: boolean }
+ >({
+ query: ({ workflow_id, is_public }) => ({
+ url: buildWorkflowsUrl(`i/${workflow_id}/is_public`),
+ method: 'PATCH',
+ body: { is_public },
+ }),
+ invalidatesTags: (result, error, { workflow_id }) => [
+ { type: 'Workflow', id: workflow_id },
+ { type: 'Workflow', id: LIST_TAG },
+ 'WorkflowCategoryCounts',
+ ],
+ }),
}),
});
@@ -173,4 +188,5 @@ export const {
useListWorkflowsInfiniteInfiniteQuery,
useSetWorkflowThumbnailMutation,
useDeleteWorkflowThumbnailMutation,
+ useUpdateWorkflowIsPublicMutation,
} = workflowsApi;
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 52a318f8160..31cc6ad6c51 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -2139,6 +2139,26 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/api/v1/workflows/i/{workflow_id}/is_public": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /**
+ * Update Workflow Is Public
+ * @description Updates whether a workflow is shared publicly
+ */
+ patch: operations["update_workflow_is_public"];
+ trace?: never;
+ };
"/api/v1/workflows/tags": {
parameters: {
query?: never;
@@ -3357,6 +3377,14 @@ export type components = {
/** @description The updated workflow */
workflow: components["schemas"]["Workflow"];
};
+ /** Body_update_workflow_is_public */
+ Body_update_workflow_is_public: {
+ /**
+ * Is Public
+ * @description Whether the workflow should be shared publicly
+ */
+ is_public: boolean;
+ };
/** Body_upload_image */
Body_upload_image: {
/**
@@ -24486,6 +24514,11 @@ export type components = {
* @description Whether multiuser mode is enabled
*/
multiuser_enabled: boolean;
+ /**
+ * Admin Email
+ * @description Email of the first active admin user, if any
+ */
+ admin_email?: string | null;
};
/**
* Show Image
@@ -27673,6 +27706,16 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
};
@@ -27703,6 +27746,16 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
/**
* Description
* @description The description of the workflow.
@@ -27726,7 +27779,7 @@ export type components = {
* @description The order by options for workflow records
* @enum {string}
*/
- WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name";
+ WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name" | "is_public";
/** WorkflowRecordWithThumbnailDTO */
WorkflowRecordWithThumbnailDTO: {
/**
@@ -27754,6 +27807,16 @@ export type components = {
* @description The opened timestamp of the workflow.
*/
opened_at?: string | null;
+ /**
+ * User Id
+ * @description The id of the user who owns this workflow.
+ */
+ user_id: string;
+ /**
+ * Is Public
+ * @description Whether this workflow is shared with all users.
+ */
+ is_public: boolean;
/** @description The workflow. */
workflow: components["schemas"]["Workflow"];
/**
@@ -32807,6 +32870,8 @@ export interface operations {
query?: string | null;
/** @description Whether to include/exclude recent workflows */
has_been_opened?: boolean | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
@@ -32981,11 +33046,49 @@ export interface operations {
};
};
};
+ update_workflow_is_public: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description The workflow to update */
+ workflow_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["Body_update_workflow_is_public"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WorkflowRecordDTO"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
get_all_tags: {
parameters: {
query?: {
/** @description The categories to include */
categories?: components["schemas"]["WorkflowCategory"][] | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
@@ -33022,6 +33125,8 @@ export interface operations {
categories?: components["schemas"]["WorkflowCategory"][] | null;
/** @description Whether to include/exclude recent workflows */
has_been_opened?: boolean | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
@@ -33058,6 +33163,8 @@ export interface operations {
categories: components["schemas"]["WorkflowCategory"][];
/** @description Whether to include/exclude recent workflows */
has_been_opened?: boolean | null;
+ /** @description Filter by public/shared status */
+ is_public?: boolean | null;
};
header?: never;
path?: never;
diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts
index 5d56c346f87..80264f792c4 100644
--- a/invokeai/frontend/web/src/services/api/types.ts
+++ b/invokeai/frontend/web/src/services/api/types.ts
@@ -337,7 +337,7 @@ export type ModelInstallStatus = S['InstallStatus'];
export type Graph = S['Graph'];
export type NonNullableGraph = SetRequired;
export type Batch = S['Batch'];
-export const zWorkflowRecordOrderBy = z.enum(['name', 'created_at', 'updated_at', 'opened_at']);
+export const zWorkflowRecordOrderBy = z.enum(['name', 'created_at', 'updated_at', 'opened_at', 'is_public']);
export type WorkflowRecordOrderBy = z.infer;
assert>();
diff --git a/tests/app/routers/test_workflows_multiuser.py b/tests/app/routers/test_workflows_multiuser.py
new file mode 100644
index 00000000000..28b301e18e3
--- /dev/null
+++ b/tests/app/routers/test_workflows_multiuser.py
@@ -0,0 +1,334 @@
+"""Tests for multiuser workflow library functionality."""
+
+import logging
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from fastapi import status
+from fastapi.testclient import TestClient
+
+from invokeai.app.api.dependencies import ApiDependencies
+from invokeai.app.api_app import app
+from invokeai.app.services.config.config_default import InvokeAIAppConfig
+from invokeai.app.services.invocation_services import InvocationServices
+from invokeai.app.services.invoker import Invoker
+from invokeai.app.services.users.users_common import UserCreateRequest
+from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
+from invokeai.backend.util.logging import InvokeAILogger
+from tests.fixtures.sqlite_database import create_mock_sqlite_database
+
+
+class MockApiDependencies(ApiDependencies):
+ invoker: Invoker
+
+ def __init__(self, invoker: Invoker) -> None:
+ self.invoker = invoker
+
+
+WORKFLOW_BODY = {
+ "name": "Test Workflow",
+ "author": "",
+ "description": "A test workflow",
+ "version": "1.0.0",
+ "contact": "",
+ "tags": "",
+ "notes": "",
+ "nodes": [],
+ "edges": [],
+ "exposedFields": [],
+ "meta": {"version": "3.0.0", "category": "user"},
+ "id": None,
+ "form_fields": [],
+}
+
+
+@pytest.fixture
+def setup_jwt_secret():
+ from invokeai.app.services.auth.token_service import set_jwt_secret
+
+ set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production")
+
+
+@pytest.fixture
+def client():
+ return TestClient(app)
+
+
+@pytest.fixture
+def mock_services() -> InvocationServices:
+ from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
+ from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
+ from invokeai.app.services.boards.boards_default import BoardService
+ from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService
+ from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import (
+ ClientStatePersistenceSqlite,
+ )
+ from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage
+ from invokeai.app.services.images.images_default import ImageService
+ from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache
+ from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService
+ from invokeai.app.services.users.users_default import UserService
+ from tests.test_nodes import TestEventService
+
+ configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0)
+ logger = InvokeAILogger.get_logger()
+ db = create_mock_sqlite_database(configuration, logger)
+
+ return InvocationServices(
+ board_image_records=SqliteBoardImageRecordStorage(db=db),
+ board_images=None, # type: ignore
+ board_records=SqliteBoardRecordStorage(db=db),
+ boards=BoardService(),
+ bulk_download=BulkDownloadService(),
+ configuration=configuration,
+ events=TestEventService(),
+ image_files=None, # type: ignore
+ image_records=SqliteImageRecordStorage(db=db),
+ images=ImageService(),
+ invocation_cache=MemoryInvocationCache(max_cache_size=0),
+ logger=logging, # type: ignore
+ model_images=None, # type: ignore
+ model_manager=None, # type: ignore
+ download_queue=None, # type: ignore
+ names=None, # type: ignore
+ performance_statistics=InvocationStatsService(),
+ session_processor=None, # type: ignore
+ session_queue=None, # type: ignore
+ urls=None, # type: ignore
+ workflow_records=SqliteWorkflowRecordsStorage(db=db),
+ tensors=None, # type: ignore
+ conditioning=None, # type: ignore
+ style_preset_records=None, # type: ignore
+ style_preset_image_files=None, # type: ignore
+ workflow_thumbnails=None, # type: ignore
+ model_relationship_records=None, # type: ignore
+ model_relationships=None, # type: ignore
+ client_state_persistence=ClientStatePersistenceSqlite(db=db),
+ users=UserService(db),
+ )
+
+
+def create_test_user(mock_invoker: Invoker, email: str, display_name: str, is_admin: bool = False) -> str:
+ user_service = mock_invoker.services.users
+ user_data = UserCreateRequest(email=email, display_name=display_name, password="TestPass123", is_admin=is_admin)
+ user = user_service.create(user_data)
+ return user.user_id
+
+
+def get_user_token(client: TestClient, email: str) -> str:
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"email": email, "password": "TestPass123", "remember_me": False},
+ )
+ assert response.status_code == 200
+ return response.json()["token"]
+
+
+@pytest.fixture
+def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker):
+ mock_invoker.services.configuration.multiuser = True
+ mock_workflow_thumbnails = MagicMock()
+ mock_workflow_thumbnails.get_url.return_value = None
+ mock_invoker.services.workflow_thumbnails = mock_workflow_thumbnails
+
+ mock_deps = MockApiDependencies(mock_invoker)
+ monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps)
+ monkeypatch.setattr("invokeai.app.api.routers.workflows.ApiDependencies", mock_deps)
+ yield
+
+
+@pytest.fixture
+def admin_token(setup_jwt_secret: None, enable_multiuser: Any, mock_invoker: Invoker, client: TestClient):
+ create_test_user(mock_invoker, "admin@test.com", "Admin", is_admin=True)
+ return get_user_token(client, "admin@test.com")
+
+
+@pytest.fixture
+def user1_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ create_test_user(mock_invoker, "user1@test.com", "User One", is_admin=False)
+ return get_user_token(client, "user1@test.com")
+
+
+@pytest.fixture
+def user2_token(enable_multiuser: Any, mock_invoker: Invoker, client: TestClient, admin_token: str):
+ create_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False)
+ return get_user_token(client, "user2@test.com")
+
+
+def create_workflow(client: TestClient, token: str) -> str:
+ response = client.post(
+ "/api/v1/workflows/",
+ json={"workflow": WORKFLOW_BODY},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200, response.text
+ return response.json()["workflow_id"]
+
+
+# ---------------------------------------------------------------------------
+# Auth tests
+# ---------------------------------------------------------------------------
+
+
+def test_list_workflows_requires_auth(enable_multiuser: Any, client: TestClient):
+ response = client.get("/api/v1/workflows/")
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+def test_create_workflow_requires_auth(enable_multiuser: Any, client: TestClient):
+ response = client.post("/api/v1/workflows/", json={"workflow": WORKFLOW_BODY})
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+# ---------------------------------------------------------------------------
+# Ownership isolation
+# ---------------------------------------------------------------------------
+
+
+def test_workflows_are_isolated_between_users(client: TestClient, user1_token: str, user2_token: str):
+ """Users should only see their own workflows in list."""
+ # user1 creates a workflow
+ create_workflow(client, user1_token)
+
+ # user1 can see it
+ r1 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user1_token}"})
+ assert r1.status_code == 200
+ assert r1.json()["total"] == 1
+
+ # user2 cannot see user1's workflow
+ r2 = client.get("/api/v1/workflows/?categories=user", headers={"Authorization": f"Bearer {user2_token}"})
+ assert r2.status_code == 200
+ assert r2.json()["total"] == 0
+
+
+def test_user_cannot_delete_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.delete(
+ f"/api/v1/workflows/i/{workflow_id}",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_user_cannot_update_another_users_workflow(client: TestClient, user1_token: str, user2_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ updated = {**WORKFLOW_BODY, "id": workflow_id, "name": "Hijacked"}
+ response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}",
+ json={"workflow": updated},
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_owner_can_delete_own_workflow(client: TestClient, user1_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.delete(
+ f"/api/v1/workflows/i/{workflow_id}",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+
+
+def test_admin_can_delete_any_workflow(client: TestClient, admin_token: str, user1_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.delete(
+ f"/api/v1/workflows/i/{workflow_id}",
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+ assert response.status_code == 200
+
+
+# ---------------------------------------------------------------------------
+# Shared workflow (is_public)
+# ---------------------------------------------------------------------------
+
+
+def test_update_is_public_owner_succeeds(client: TestClient, user1_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["is_public"] is True
+
+
+def test_update_is_public_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str):
+ workflow_id = create_workflow(client, user1_token)
+ response = client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+
+def test_public_workflow_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
+ """A shared (is_public=True) workflow should appear when filtering with is_public=true."""
+ workflow_id = create_workflow(client, user1_token)
+ # Make it public
+ client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # user2 can see it through is_public=true filter
+ response = client.get(
+ "/api/v1/workflows/?categories=user&is_public=true",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == 200
+ ids = [w["workflow_id"] for w in response.json()["items"]]
+ assert workflow_id in ids
+
+
+def test_private_workflow_not_visible_to_other_users(client: TestClient, user1_token: str, user2_token: str):
+ """A private (is_public=False) user workflow should NOT appear for another user."""
+ workflow_id = create_workflow(client, user1_token)
+
+ # user2 lists 'yours' style (their own workflows)
+ response = client.get(
+ "/api/v1/workflows/?categories=user",
+ headers={"Authorization": f"Bearer {user2_token}"},
+ )
+ assert response.status_code == 200
+ ids = [w["workflow_id"] for w in response.json()["items"]]
+ assert workflow_id not in ids
+
+
+def test_public_workflow_still_in_owners_list(client: TestClient, user1_token: str):
+ """A shared workflow should still appear in the owner's own workflow list."""
+ workflow_id = create_workflow(client, user1_token)
+ client.patch(
+ f"/api/v1/workflows/i/{workflow_id}/is_public",
+ json={"is_public": True},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+
+ # owner's 'yours' list (no is_public filter)
+ response = client.get(
+ "/api/v1/workflows/?categories=user",
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+ ids = [w["workflow_id"] for w in response.json()["items"]]
+ assert workflow_id in ids
+
+
+def test_workflow_has_user_id_and_is_public_fields(client: TestClient, user1_token: str):
+ """Created workflow should return user_id and is_public fields."""
+ response = client.post(
+ "/api/v1/workflows/",
+ json={"workflow": WORKFLOW_BODY},
+ headers={"Authorization": f"Bearer {user1_token}"},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert "user_id" in data
+ assert "is_public" in data
+ assert data["is_public"] is False