Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ca7df9a
Initial plan
Copilot Jan 21, 2026
45643fb
Convert session queue isolation logs from info to debug level
Copilot Jan 21, 2026
ec2f797
Add JWT secret storage in database and app_settings service
Copilot Jan 21, 2026
339abb6
Add multiuser configuration option with default false
Copilot Jan 21, 2026
f6a4224
Update token service tests to initialize JWT secret
Copilot Jan 21, 2026
11b47db
Fix app_settings_service to use proper database transaction pattern
Copilot Jan 21, 2026
f482059
chore(backend): typegen and ruff
lstein Jan 21, 2026
38cd6ac
chore(docs): update docstrings
lstein Jan 21, 2026
3c5ee94
Fix frontend to bypass authentication in single-user mode
Copilot Jan 21, 2026
61b0379
Fix auth tests to enable multiuser mode
Copilot Jan 21, 2026
0ba2924
Fix model manager UI visibility in single-user mode
Copilot Jan 21, 2026
d304200
chore(backend): ruff
lstein Jan 21, 2026
325fa4d
Fix TypeScript lint errors
Copilot Jan 21, 2026
6aa89f8
chore(frontend): typegen
lstein Jan 21, 2026
0f48c65
Merge remote-tracking branch 'refs/remotes/origin/copilot/make-multiu…
lstein Jan 21, 2026
fa92777
Fix test_data_isolation to enable multiuser mode
Copilot Jan 21, 2026
5283996
Redirect login and setup pages to app in single-user mode
Copilot Jan 21, 2026
b109f0b
Fix test_auth.py to initialize JWT secret
Copilot Jan 21, 2026
57aa2cc
Prevent login form flash in single-user mode
Copilot Jan 21, 2026
7fa4846
Fix board and queue operations in single-user mode
Copilot Jan 21, 2026
bbced6e
Add user management utilities and rename add_user.py
Copilot Jan 21, 2026
b9ac76a
Fix ESLint errors in frontend code
Copilot Jan 22, 2026
7d00a8a
Add userlist.py script for viewing database users
Copilot Jan 22, 2026
ee98d83
Fix test_boards_multiuser.py test failures
Copilot Jan 22, 2026
21974d7
chore(backend): ruff
lstein Jan 22, 2026
12fb2fa
Fix userlist.py SqliteDatabase initialization
Copilot Jan 22, 2026
c06849b
Fix test_boards_multiuser.py by adding app_settings service to mock
Copilot Jan 22, 2026
b9cbbbf
bugfix(scripts): fix crash in userlist.py script
lstein Jan 22, 2026
fe2c55b
Fix test_boards_multiuser.py JWT secret initialization
Copilot Jan 22, 2026
7fd8168
Fix CurrentUserOrDefault to require auth in multiuser mode
Copilot Jan 22, 2026
edefb82
chore(front & backend): ruff and lint
lstein Jan 22, 2026
c47af8f
Add AdminUserOrDefault and fix model settings in single-user mode
Copilot Jan 22, 2026
a504942
Fix model manager operations in single-user mode
Copilot Jan 22, 2026
692f62c
Fix syntax error in model_manager.py
Copilot Jan 22, 2026
42ad83d
Fix FastAPI dependency injection syntax error
Copilot Jan 22, 2026
e94aab3
Fix delete_model endpoint parameter annotation
Copilot Jan 22, 2026
5ea2370
Fix parameter annotations for all AdminUserOrDefault endpoints
Copilot Jan 22, 2026
c57b528
Revert to correct AdminUserOrDefault usage pattern
Copilot Jan 22, 2026
236087e
Fix parameter order for AdminUserOrDefault in model manager
Copilot Jan 22, 2026
eca89ca
chore(frontend): typegen
lstein Jan 22, 2026
3f30cd1
chore(frontend): typegen again
lstein Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ help:

# Runs ruff, fixing any safely-fixable errors and formatting
ruff:
cd invokeai && uv tool run ruff@0.11.2 format .
cd invokeai && uv tool run ruff@0.11.2 format

# Runs ruff, fixing all errors it can fix and formatting
ruff-unsafe:
ruff check . --fix --unsafe-fixes
ruff format .
ruff format

# Runs mypy, using the config in pyproject.toml
mypy:
Expand Down Expand Up @@ -64,8 +64,12 @@ frontend-dev:
frontend-typegen:
cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen

frontend-prettier:
cd invokeai/frontend/web/src && pnpm lint:prettier --write
frontend-lint:
cd invokeai/frontend/web/src && \
pnpm lint:tsc && \
pnpm lint:dpdm && \
pnpm lint:eslint --fix && \
pnpm lint:prettier --write

# Tag the release
wheel:
Expand Down
62 changes: 50 additions & 12 deletions invokeai/app/api/auth_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,36 +69,49 @@ async def get_current_user_or_default(
) -> TokenData:
"""Get current authenticated user from Bearer token, or return a default system user if not authenticated.

This dependency is useful for endpoints that should work in both authenticated and non-authenticated contexts.
In single-user mode or when authentication is not provided, it returns a TokenData for the 'system' user.
This dependency is useful for endpoints that should work in both single-user and multiuser modes.

When multiuser mode is disabled (default), this always returns a system user with admin privileges,
allowing unrestricted access to all operations.

When multiuser mode is enabled, authentication is required and this function validates the token,
returning authenticated user data or raising 401 Unauthorized if no valid credentials are provided.

Args:
credentials: The HTTP authorization credentials containing the Bearer token

Returns:
TokenData containing user information from the token, or system user if no credentials
TokenData containing user information from the token, or system user in single-user mode

Raises:
HTTPException: 401 Unauthorized if in multiuser mode and credentials are missing, invalid, or user is inactive
"""
# Get configuration to check if multiuser is enabled
config = ApiDependencies.invoker.services.configuration

# In single-user mode (multiuser=False), always return system user with admin privileges
if not config.multiuser:
return TokenData(user_id="system", email="system@system.invokeai", is_admin=True)

# Multiuser mode is enabled - validate credentials
if credentials is None:
# Return system user for unauthenticated requests (single-user mode or backwards compatibility)
logger.debug("No authentication credentials provided, using system user")
return TokenData(user_id="system", email="system@system.invokeai", is_admin=False)
# In multiuser mode, authentication is required
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")

token = credentials.credentials
token_data = verify_token(token)

if token_data is None:
# Invalid token - still fall back to system user for backwards compatibility
logger.warning("Invalid or expired token provided, falling back to system user")
return TokenData(user_id="system", email="system@system.invokeai", is_admin=False)
# Invalid token in multiuser mode - reject
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")

# Verify user still exists and is active
user_service = ApiDependencies.invoker.services.users
user = user_service.get(token_data.user_id)

if user is None or not user.is_active:
# User doesn't exist or is inactive - fall back to system user
logger.warning(f"User {token_data.user_id} does not exist or is inactive, falling back to system user")
return TokenData(user_id="system", email="system@system.invokeai", is_admin=False)
# User doesn't exist or is inactive in multiuser mode - reject
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")

return token_data

Expand All @@ -122,7 +135,32 @@ async def require_admin(
return current_user


async def require_admin_or_default(
current_user: Annotated[TokenData, Depends(get_current_user_or_default)],
) -> TokenData:
"""Require admin role for the current user, or return default system admin in single-user mode.

This dependency is useful for admin-only endpoints that should work in both single-user and multiuser modes.

When multiuser mode is disabled (default), this always returns a system user with admin privileges.
When multiuser mode is enabled, this validates that the authenticated user has admin privileges.

Args:
current_user: The current authenticated user's token data (or default system user)

Returns:
The token data if user is an admin (or system user in single-user mode)

Raises:
HTTPException: If user does not have admin privileges (403 Forbidden) in multiuser mode
"""
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
return current_user


# Type aliases for convenient use in route dependencies
CurrentUser = Annotated[TokenData, Depends(get_current_user)]
CurrentUserOrDefault = Annotated[TokenData, Depends(get_current_user_or_default)]
AdminUser = Annotated[TokenData, Depends(require_admin)]
AdminUserOrDefault = Annotated[TokenData, Depends(require_admin_or_default)]
8 changes: 8 additions & 0 deletions invokeai/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import torch

from invokeai.app.services.app_settings import AppSettingsService
from invokeai.app.services.auth.token_service import set_jwt_secret
from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage
from invokeai.app.services.board_images.board_images_default import BoardImagesService
from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage
Expand Down Expand Up @@ -102,6 +104,12 @@ def initialize(

db = init_db(config=config, logger=logger, image_files=image_files)

# Initialize JWT secret from database
app_settings = AppSettingsService(db=db)
jwt_secret = app_settings.get_jwt_secret()
set_jwt_secret(jwt_secret)
logger.info("JWT secret loaded from database")

configuration = config
logger = logger

Expand Down
32 changes: 30 additions & 2 deletions invokeai/app/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,27 @@ class SetupStatusResponse(BaseModel):
"""Response for setup status check."""

setup_required: bool = Field(description="Whether initial setup is required")
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")


@auth_router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status() -> SetupStatusResponse:
"""Check if initial administrator setup is required.

Returns:
SetupStatusResponse indicating whether setup is needed
SetupStatusResponse indicating whether setup is needed and multiuser mode status
"""
config = ApiDependencies.invoker.services.configuration

# If multiuser is disabled, setup is never required
if not config.multiuser:
return SetupStatusResponse(setup_required=False, multiuser_enabled=False)

# In multiuser mode, check if an admin exists
user_service = ApiDependencies.invoker.services.users
setup_required = not user_service.has_admin()

return SetupStatusResponse(setup_required=setup_required)
return SetupStatusResponse(setup_required=setup_required, multiuser_enabled=True)


@auth_router.post("/login", response_model=LoginResponse)
Expand All @@ -100,7 +108,17 @@ async def login(

Raises:
HTTPException: 401 if credentials are invalid or user is inactive
HTTPException: 403 if multiuser mode is disabled
"""
config = ApiDependencies.invoker.services.configuration

# Check if multiuser is enabled
if not config.multiuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Multiuser mode is disabled. Authentication is not required in single-user mode.",
)

user_service = ApiDependencies.invoker.services.users
user = user_service.authenticate(request.email, request.password)

Expand Down Expand Up @@ -195,7 +213,17 @@ async def setup_admin(

Raises:
HTTPException: 400 if admin already exists or password is weak
HTTPException: 403 if multiuser mode is disabled
"""
config = ApiDependencies.invoker.services.configuration

# Check if multiuser is enabled
if not config.multiuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Multiuser mode is disabled. Admin setup is not required in single-user mode.",
)

user_service = ApiDependencies.invoker.services.users

# Check if any admin exists
Expand Down
12 changes: 6 additions & 6 deletions invokeai/app/api/routers/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field

from invokeai.app.api.auth_dependencies import CurrentUser
from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy
from invokeai.app.services.boards.boards_common import BoardDTO
Expand Down Expand Up @@ -33,7 +33,7 @@ class DeleteBoardResult(BaseModel):
response_model=BoardDTO,
)
async def create_board(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
board_name: str = Query(description="The name of the board to create", max_length=300),
) -> BoardDTO:
"""Creates a board for the current user"""
Expand All @@ -46,7 +46,7 @@ async def create_board(

@boards_router.get("/{board_id}", operation_id="get_board", response_model=BoardDTO)
async def get_board(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
board_id: str = Path(description="The id of board to get"),
) -> BoardDTO:
"""Gets a board (user must have access to it)"""
Expand All @@ -70,7 +70,7 @@ async def get_board(
response_model=BoardDTO,
)
async def update_board(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
board_id: str = Path(description="The id of board to update"),
changes: BoardChanges = Body(description="The changes to apply to the board"),
) -> BoardDTO:
Expand All @@ -84,7 +84,7 @@ async def update_board(

@boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult)
async def delete_board(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
board_id: str = Path(description="The id of board to delete"),
include_images: Optional[bool] = Query(description="Permanently delete all images on the board", default=False),
) -> DeleteBoardResult:
Expand Down Expand Up @@ -125,7 +125,7 @@ async def delete_board(
response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]],
)
async def list_boards(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
order_by: BoardRecordOrderBy = Query(default=BoardRecordOrderBy.CreatedAt, description="The attribute to order by"),
direction: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The direction to order by"),
all: Optional[bool] = Query(default=None, description="Whether to list all boards"),
Expand Down
8 changes: 4 additions & 4 deletions invokeai/app/api/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from PIL import Image
from pydantic import BaseModel, Field, model_validator

from invokeai.app.api.auth_dependencies import CurrentUser
from invokeai.app.api.auth_dependencies import CurrentUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_image
from invokeai.app.invocations.fields import MetadataField
Expand Down Expand Up @@ -62,7 +62,7 @@ def validate_total_output_size(self):
response_model=ImageDTO,
)
async def upload_image(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
file: UploadFile,
request: Request,
response: Response,
Expand Down Expand Up @@ -376,7 +376,7 @@ async def get_image_urls(
response_model=OffsetPaginatedResults[ImageDTO],
)
async def list_image_dtos(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
Expand Down Expand Up @@ -580,7 +580,7 @@ async def get_bulk_download_item(

@images_router.get("/names", operation_id="get_image_names")
async def get_image_names(
current_user: CurrentUser,
current_user: CurrentUserOrDefault,
image_origin: Optional[ResourceOrigin] = Query(default=None, description="The origin of images to list."),
categories: Optional[list[ImageCategory]] = Query(default=None, description="The categories of image to include."),
is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate images."),
Expand Down
Loading
Loading