From b90969ee8840116a233d11b536d5b65f66d7501a Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Fri, 27 Feb 2026 05:01:08 +0200 Subject: [PATCH 01/24] Fix(Text-tool): Remove redundant Font tooltip on fonts selection dropdown. (#8906) --- .../components/Text/TextToolOptions.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx index 69c96c18f9..5dce61de4d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx @@ -96,16 +96,14 @@ const FontSelect = () => { {t('controlLayers.text.font', { defaultValue: 'Font' })} - - - + ); }; From 146b9368448699c6310cff3e0558c98e8f98d682 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 26 Feb 2026 23:47:25 -0500 Subject: [PATCH 02/24] feat(multiuser mode): Support multiple isolated users on same backend (#8822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add comprehensive multi-user support specification and implementation plan Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Clarify Python tooling transition state Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add executive summary for multi-user support specification Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Reorganize multiuser docs into subfolder and update with approved design decisions Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * docs: fix mkdocs syntax issues * Fix Z-Image VAE operations not reserving working memory for OOM prevention (#8) * Initial plan * Fix Z-Image VAE encode/decode to request working memory Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add test for Z-Image working memory estimation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix unit test: only set config attribute for AutoencoderKL, not FluxAutoEncoder Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * style: fix ruff errors * Fix test: use model_construct to bypass Pydantic validation for mock fields Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(ruff): fix ruff errors --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Phase 1: Add multi-user database schema and authentication foundation (#6) * Initial plan * Add Phase 1: Database schema, authentication utilities, and user service - Added dependencies: passlib[bcrypt], python-jose[cryptography], email-validator - Created migration_25 for multi-user support database schema - users table with authentication fields - user_sessions, user_invitations, shared_boards tables - Added user_id columns to boards, images, workflows, session_queue, style_presets - Created system user for backward compatibility - Implemented authentication utilities - Password hashing and validation (password_utils.py) - JWT token generation and verification (token_service.py) - Implemented user service - Abstract base class (users_base.py) - SQLite implementation (users_default.py) - Full CRUD operations for users - Authentication and admin management - Added comprehensive tests for all components - All code passes ruff linting and mypy type checking Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Refactor user service to use transaction pattern and improve security documentation - Changed all database operations to use self._db.transaction() context manager - Follows the same pattern as other services like board_records - Improves thread safety and transaction management - Added detailed security warnings for the hardcoded JWT secret key - Documented that it must be moved to config before production - Fixed whitespace linting issues - All tests still pass with the refactored code Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix system user email to use valid domain - Changed system user email from system@invokeai.local to system@system.invokeai - .local domains are rejected by email-validator as special-use/reserved - All end-to-end tests now pass successfully Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix test failures: password length, migration chain, and table existence checks - Fixed bcrypt 72-byte password limit by truncating long passwords in hash_password and verify_password - Fixed test_migration_25 to use callback directly instead of migrator to avoid fragmented chain error - Updated system user email assertion in test from @invokeai.local to @system.invokeai - Added table existence checks for boards, images, workflows, and session_queue before altering them - This prevents "no such table" errors when migration runs in environments without all tables Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * (chore) fix ruff errors * (chore) fix ruff errors (2) * Fix test failures: use _conn instead of conn and improve password truncation - Fixed test_user_service.py to use db._conn instead of db.conn (SqliteDatabase uses private _conn) - Simplified password truncation logic in password_utils.py for better clarity - Ensured UTF-8 decoding handles truncated bytes properly with errors="ignore" Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(uv): updated uv lockfile * Fix bcrypt password length errors by configuring passlib properly - Added bcrypt__relax_truncate_checks=True to CryptContext to prevent errors on long passwords - Removed min_length=8 constraint from pydantic models to allow service-level validation - Service-level validation provides better error messages and more control - Manual truncation code kept as safety net for passwords >72 bytes Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix bcrypt parameter name: use truncate_error instead of relax_truncate_checks - Changed bcrypt__relax_truncate_checks=True to bcrypt__truncate_error=False - The correct passlib parameter is truncate_error (not relax_truncate_checks) - Setting it to False allows passwords >72 bytes without raising an error Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix(passwords) downgrade bcrypt to work with current passlib * chore(uv): update locks file --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Implement Phase 2: Authentication Service with JWT and FastAPI Integration (#11) * Initial plan * Add Phase 2 authentication service - auth dependencies, router, and service integration Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Address code review feedback - add token expiration constants and improve documentation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix email validation to allow special-use domains like .local for testing Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix system user to not be admin - allows /auth/setup to work on fresh database Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore: typegen --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Add Phase 3 integration tests and documentation for authentication middleware (#15) * Initial plan * Add Phase 3 integration tests for auth router endpoints Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add Phase 3 completion documentation and testing guide Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore: ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * chore: fix unresolved merged conflict markers * Phase 4: Add multi-tenancy to boards and session queue services (#17) * Initial plan * Update boards service for multi-user support - Add user_id parameter to board creation and filtering - Update board_records base and SQLite implementation to filter by user - Include shared boards and public boards in user queries - Update boards router to require authentication - Update invocation context to pass user_id from queue item - Add user_id field to SessionQueueItem model Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Update session_queue service for multi-user support - Add user_id parameter to enqueue_batch method - Update ValueToInsertTuple to include user_id - Update prepare_values_to_insert to accept and use user_id - Update SQL INSERT statements to include user_id column - Update session_queue router to require authentication - Update retry_items_by_id to preserve user_id when retrying Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add authentication imports to images, workflows, and style_presets routers - Import CurrentUser dependency in all three routers - Add CurrentUser parameter to upload_image endpoint - Prepare for further authentication requirements in these services Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add multiuser tests and Phase 4 documentation - Create test_boards_multiuser.py with authentication tests - Verify board operations require authentication - Test board creation and listing with auth tokens - Add comprehensive Phase 4 implementation summary - Document all changes, data flows, and security considerations Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add authentication to remaining board endpoints - Add CurrentUser to get_board endpoint - Add CurrentUser to update_board endpoint - Add CurrentUser to delete_board endpoint - Ensures all board operations require authentication - Addresses code review feedback Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Feature(image boards): Implement per-user board isolation - Complete verification report with all checks passed - Document code quality, security, and testing results - List all achievements and sign-off criteria - Mark phase as READY FOR MERGE Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore: ruff * chore: resolve conflicts in z_image_working_memory test * chore: ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * feat: Implement Phase 5 of multiuser plan - Frontend authentication (#19) * Phase 5: Implement frontend authentication infrastructure - Created auth slice with Redux state management for authentication - Created auth API endpoints (login, logout, setup, me) - Created LoginPage component for user authentication - Created AdministratorSetup component for initial admin setup - Created ProtectedRoute wrapper for route authentication checking - Updated API configuration to include Authorization headers - Installed and configured react-router-dom for routing - Updated App component with authentication routes - All TypeScript checks passing Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(style): prettier, typegen and add convenience targets to makefile --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * feat: Implement Phase 6 frontend UI updates - UserMenu and admin restrictions Co-authored-by: lstein <111189+lstein@users.noreply.github.com> docs: Add comprehensive testing and verification documentation for Phase 6 Co-authored-by: lstein <111189+lstein@users.noreply.github.com> docs: Add Phase 6 summary document Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * feat: Add user management script for testing multiuser features Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * feat: Implement read-only model manager access for non-admin users Co-authored-by: lstein <111189+lstein@users.noreply.github.com> feat: Add admin authorization to model management API endpoints Co-authored-by: lstein <111189+lstein@users.noreply.github.com> docs: Update specification and implementation plan for read-only model manager Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Phase 7: Comprehensive testing and security validation for multiuser authentication (#23) * Initial plan * Phase 7: Complete test suite with 88 comprehensive tests - Add password utils tests (31 tests): hashing, verification, validation - Add token service tests (20 tests): JWT creation, verification, security - Add security tests (13 tests): SQL injection, XSS, auth bypass prevention - Add data isolation tests (11 tests): multi-user data separation - Add performance tests (13 tests): benchmarks and scalability - Add comprehensive testing documentation - Add phase 7 verification report Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * bugfix(backend): Fix issues with authentication token expiration handling - Remove time.sleep from token uniqueness test (use different expiration instead) - Increase token expiration test time from 1 microsecond to 10 milliseconds - More reliable test timing to prevent flakiness Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Add Phase 7 summary documentation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix test_performance.py missing logger fixture Add missing logger fixture to test_performance.py that was causing test failures. The fixture creates a Logger instance needed by the user_service fixture. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Add board isolation issue specification document Document the three board isolation issues that need to be addressed: 1. Board list not updating when switching users 2. "Uncategorized" board shared among users 3. Admin cannot access all users' boards Includes technical details, implementation plan, and acceptance criteria. This document will be used to create a separate GitHub issue and PR. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix test failures in Phase 7 tests - Fix board service API calls to use enum values (BoardRecordOrderBy, SQLiteDirection) instead of strings - Fix board deletion test to use get_dto() instead of non-existent get() method - Add exception handling to verify_password() for invalid hash formats - Update SQL injection test to accept both 401 and 422 status codes (Pydantic validation) All fixes ensure tests match actual API signatures and handle edge cases properly. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix token forgery test to properly decode and modify JWT payload The test was attempting to modify the JWT payload by string replacement on the base64-encoded data, which doesn't work since "false" doesn't appear literally in the base64 encoding. Fixed to: - Properly decode the base64 payload - Parse the JSON - Modify the is_admin field - Re-encode the payload - Create a forged token with the modified payload and original signature - Verify it's rejected with 401 status This properly tests that JWT signature verification prevents token forgery. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix test_verify_expired_token timing issue Increased token expiration from 10ms to 100ms and wait time from 20ms to 200ms to make the test more reliable. The previous timing was too tight and could cause race conditions where the token hadn't actually expired yet when verified. This ensures the test reliably validates that expired tokens are properly rejected. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix token verification to properly validate signatures and expiration The verify_token() function was not explicitly enabling JWT verification options, causing it to accept expired tokens and tokens with modified signatures/payloads. Fixed by explicitly setting verification options: - verify_signature: True (reject modified tokens) - verify_exp: True (reject expired tokens) - verify_nbf: True (verify "not before" claim) - verify_iat: True (verify "issued at" claim) - verify_aud: False (we don't use audience claims) Also added catch-all exception handler for other validation errors. This ensures all token security tests pass properly. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Revert to default python-jose verification behavior Removed explicit options parameter from jwt.decode() call since python-jose verifies signatures and expiration by default. The explicit options may have been causing issues or conflicts with the library's default behavior. Added comments clarifying that python-jose defaults are secure and handle verification properly. Need user to test and provide debugging output if issues persist. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix JWT expiration verification for python-jose 3.5.0 python-jose 3.5.0 has a bug where expiration verification doesn't work properly by default. The jwt.decode() function is not rejecting expired tokens even when they should be rejected. Workaround implemented: 1. First, get unverified claims to extract the 'exp' timestamp 2. Manually check if current time >= exp time (token is expired) 3. Return None immediately if expired 4. Then verify signature with jwt.decode() for tokens that aren't expired This ensures: - Expired tokens are properly rejected - Signature verification still happens for non-expired tokens - Modified tokens are rejected due to signature mismatch All three failing tests should now pass: - test_verify_expired_token - test_verify_token_with_modified_payload - test_token_signature_verification Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix race condition in token verification - verify signature before expiration Changed the order of verification in verify_token(): 1. First verify signature with jwt.decode() - rejects modified/forged tokens 2. Then manually check expiration timestamp Previous implementation checked expiration first using get_unverified_claims(), which could cause a race condition where: - Token with valid payload but INVALID signature would pass expiration check - If expiration check happened to return None due to timing, signature was never verified - Modified tokens could be accepted intermittently New implementation ensures signature is ALWAYS verified first, preventing any modified tokens from being accepted, while still working around the python-jose 3.5.0 expiration bug by manually checking expiration after signature verification. This eliminates the non-deterministic test failures in test_verify_token_with_modified_payload. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(app): ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Backend: Add admin board filtering and uncategorized board isolation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix intermittent token service test failures caused by Base64 padding (#32) * Initial plan * Fix intermittent token service test failures due to Base64 padding Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Address code review: add constants for magic numbers in tests Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(tests): ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Implement user isolation for session queue and socket events (WIP - debugging queue visibility) (#30) * Add user isolation for queue events and field values filtering Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add user column to queue list UI Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add field values privacy indicator and implementation documentation Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Allow all users to see queue item status events while keeping invocation events private Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(backend): ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Fix Queue tab not updating for other users in real-time (#34) * Initial plan * Add SessionQueueItemIdList invalidation to queue socket events This ensures the queue item list updates in real-time for all users when queue events occur (status changes, batch enqueued, queue cleared). Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add SessionQueueItemIdList invalidation to queue_items_retried event Ensures queue list updates when items are retried. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Improve queue_items_retried event and mutation invalidation - Add individual item invalidation to queue_items_retried event handler - Add SessionQueueStatus and BatchStatus tags to retryItemsById mutation - Ensure consistency between event handler and mutation invalidation patterns Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add privacy check for batch field values in Queue tab Displays "Hidden for privacy" message for non-admin users viewing queue items they don't own, instead of showing the actual field values. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * i18n(frontend): change wording of queue values suppressed message * Add SessionQueueItemIdList cache invalidation to queue events Ensures real-time queue updates for all users by invalidating the SessionQueueItemIdList cache tag when queue events occur. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Fix multiuser information leakage in Queue panel detail view (#38) * Initial plan * Implement multiuser queue information leakage fix - Backend: Update sanitize_queue_item_for_user to clear session graph and workflow - Frontend: Add permission check to disable detail view for unauthorized users - Add test for sanitization logic - Add translation key for permission denied message Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix prettier formatting for QueueItemComponent Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Address code review feedback - Move Graph and GraphExecutionState imports to top of file - Remove dependency on test_nodes in sanitization test - Create minimal test invocation directly in test file Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Address additional code review feedback - Create shallow copy to avoid mutating original queue_item - Extract 'system' user_id to constant (SYSTEM_USER_ID) - Add constant to both backend and frontend for consistency Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix pydantic validation error in test fixture Add required timestamp fields (created_at, updated_at, started_at, completed_at) to SessionQueueItem in test fixture Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix(queue): Enforce user permissions for queue operations in multiuser mode (#36) * Initial plan * Add backend authorization checks for queue operations Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix linting issues in authorization changes Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add frontend authorization checks for queue operations Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add access denied messages for cancel and clear operations Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix access denied messages for all cancel/delete operations Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix merge conflict duplicates in QueueItemComponent Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(frontend): typegen --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * fix(multiuser): Isolate client state per user to prevent data leakage (#40) * Implement per-user client state storage to fix multiuser leakage Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix: Make authentication optional for client_state endpoints to support single-user mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Clear params state on logout/login to prevent user data leakage Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * feat(queue): show user/total pending jobs in multiuser mode badge (#43) * Initial plan * Add multiuser queue badge support - show X/Y format in multiuser mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Format openapi.json with Prettier Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Address code review feedback - optimize DB queries and improve code clarity Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * translationBot(ui): update translation files (#8767) Updated by "Cleanup translation files" hook in Weblate. Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI * Limit automated issue closure to bug issues only (#8776) * Initial plan * Add only-labels parameter to limit automated issue closure to bugs only Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix(multiuser): Isolate client state per user to prevent data leakage (#40) * Implement per-user client state storage to fix multiuser leakage Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix: Make authentication optional for client_state endpoints to support single-user mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Clear params state on logout/login to prevent user data leakage Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Initial plan * chore(backend) ruff & typegen * Fix real-time badge updates by invalidating SessionQueueStatus on queue events Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Weblate (bot) Co-authored-by: Lincoln Stein * Convert session queue isolation logs from info to debug level Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add JWT secret storage in database and app_settings service Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add multiuser configuration option with default false Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Update token service tests to initialize JWT secret Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix app_settings_service to use proper database transaction pattern Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(backend): typegen and ruff * chore(docs): update docstrings * Fix frontend to bypass authentication in single-user mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix auth tests to enable multiuser mode Auth tests were failing because the login and setup endpoints now return 403 when multiuser mode is disabled (the default). Updated test fixtures to enable multiuser mode for all auth-related tests. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix model manager UI visibility in single-user mode Model manager UI for adding, deleting and modifying models is now: - Visible in single-user mode (multiuser: false, the default) - Hidden in multiuser mode for non-admin users - Visible in multiuser mode for admin users Created useIsModelManagerEnabled hook that checks multiuser_enabled status and returns true when multiuser is disabled OR when user is admin. Updated all model manager components to use this hook instead of direct is_admin checks. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(backend): ruff * chore(frontend): typegen * Fix TypeScript lint errors - Added multiuser_enabled field to SetupStatusResponse type in auth.ts - Removed unused user variable reference in MainModelDefaultSettings.tsx Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix test_data_isolation to enable multiuser mode Added fixture to enable multiuser mode for data isolation tests, similar to other auth tests. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Redirect login and setup pages to app in single-user mode When multiuser mode is disabled, the LoginPage and AdministratorSetup components now redirect to /app instead of showing the login/setup forms. This prevents users from being stuck on the login page after browser refresh in single-user mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix test_auth.py to initialize JWT secret Added setup_jwt_secret fixture to test_auth.py to initialize the JWT secret before running auth tests. This fixture was missing, causing token creation/verification to fail in auth router tests. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Prevent login form flash in single-user mode Show loading spinner instead of login/setup forms when multiuser mode is disabled or when redirecting is about to happen. This prevents the unattractive flash of the login dialog when refreshing the page in single-user mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix board and queue operations in single-user mode Changed boards, session_queue, and images routers to use CurrentUserOrDefault instead of CurrentUser. This allows these endpoints to work without authentication when multiuser mode is disabled (default), fixing the issue where users couldn't create boards or add jobs to the queue in single-user mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add user management utilities and rename add_user.py Created three user management scripts in the scripts/ directory: - useradd.py (renamed from add_user.py) - add users with admin privileges - userdel.py - delete users by email address with confirmation - usermod.py - modify user details (name, password, admin status) All scripts support both CLI and interactive modes for flexibility. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix ESLint errors in frontend code - Fixed brace-style issue in App.tsx (else-if on same line) - Removed unused useAppSelector imports from model manager components - Fixed import sorting in ControlAdapterModelDefaultSettings.tsx Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add userlist.py script for viewing database users Created scripts/userlist.py to display all users in the database. Supports: - Table format (default): Shows ID, email, display name, admin status, and active status - JSON format (--json flag): Outputs user data as JSON for scripting/automation Example usage: python scripts/userlist.py # Table view python scripts/userlist.py --json # JSON output Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix test_boards_multiuser.py test failures Fixed test failures caused by ApiDependencies.invoker not being set properly: - Added setup_jwt_secret fixture to initialize JWT secret for token generation - Added enable_multiuser_for_tests fixture that sets ApiDependencies.invoker as a class attribute - Updated tests to use enable_multiuser_for_tests fixture to ensure ApiDependencies is properly configured - Removed MockApiDependencies class approach in favor of directly setting the class attribute This fixes the AttributeError and ensures all tests have the proper setup. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(backend): ruff * Fix userlist.py SqliteDatabase initialization Fixed AttributeError in userlist.py where SqliteDatabase was being passed the config object instead of config.db_path. The constructor expects a Path object (db_path) as the first argument, not the entire config object. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix test_boards_multiuser.py by adding app_settings service to mock Added AppSettingsService initialization to the mock_services fixture in tests/conftest.py. The test was failing because setup_jwt_secret fixture expected mock_invoker.services.app_settings to exist, but it wasn't being initialized in the mock services. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * bugfix(scripts): fix crash in userlist.py script * Fix test_boards_multiuser.py JWT secret initialization Fixed setup_jwt_secret fixture to call set_jwt_secret() directly instead of trying to access non-existent app_settings service. Removed incorrect app_settings parameter from InvocationServices initialization in tests/conftest.py since app_settings is not an attribute of InvocationServices. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix CurrentUserOrDefault to require auth in multiuser mode Changed get_current_user_or_default to raise HTTP 401 when multiuser mode is enabled and credentials are missing, invalid, or the user is inactive. This ensures that board/queue/image operations require authentication in multiuser mode while still working without authentication in single-user mode (default). Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(front & backend): ruff and lint * Add AdminUserOrDefault and fix model settings in single-user mode Created AdminUserOrDefault dependency that allows admin operations to work without authentication in single-user mode while requiring admin privileges in multiuser mode. Updated model_manager router to use AdminUserOrDefault for update_model_record, update_model_image, and reidentify_model endpoints. This fixes the "Missing authentication credentials" error when saving model default settings in single-user mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix model manager operations in single-user mode Changed all model manager endpoints from AdminUser to AdminUserOrDefault to allow model installation, deletion, conversion, and cache management operations to work without authentication in single-user mode. This fixes the issue where users couldn't add or delete models in single-user mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix syntax error in model_manager.py Added Depends(AdminUserOrDefault) to all AdminUserOrDefault dependency parameters to fix Python syntax error where parameters without defaults were following parameters with defaults. Imported Depends from fastapi. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix FastAPI dependency injection syntax error Removed type annotations from AdminUserOrDefault dependency parameters. FastAPI doesn't allow both Annotated type hints and = Depends() default values together. Changed from `_: AdminUserOrDefault = Depends(AdminUserOrDefault)` to `_ = Depends(AdminUserOrDefault)` throughout model_manager.py. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix delete_model endpoint parameter annotation Changed delete_model endpoint to use Annotated[str, Path(...)] instead of str = Path(...) to match FastAPI's preferred syntax and fix the 422 Unprocessable Entity error when deleting models in single-user mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix parameter annotations for all AdminUserOrDefault endpoints Changed all endpoints using AdminUserOrDefault from old syntax (key: str = Path(...)) to FastAPI's preferred Annotated syntax (key: Annotated[str, Path(...)]). This fixes 422 Unprocessable Entity errors when updating model settings and deleting models in single-user mode. Updated endpoints: delete_model_image, install_model, install_hugging_face_model, and convert_model. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Revert to correct AdminUserOrDefault usage pattern Reverted model_manager.py to state before commit c47af8f and reapplied AdminUserOrDefault correctly. Changed from `_: AdminUser` to `current_admin: AdminUserOrDefault` using the same pattern as boards.py (`current_user: CurrentUserOrDefault`). This fixes all 422 errors in single-user mode while maintaining proper admin authentication in multiuser mode. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix parameter order for AdminUserOrDefault in model manager Moved current_admin: AdminUserOrDefault parameter before all parameters with default values in model_manager.py endpoints. Python requires parameters without defaults to come before parameters with defaults. Fixed 8 endpoints: delete_model, bulk_delete_models, delete_model_image, install_model, install_hugging_face_model, cancel_model_install_job, convert_model, and do_hf_login. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(frontend): typegen * chore(frontend): typegen again * Docs(app): Comprehensive Documentation of Multiuser Features (#50) * Implement Phase 8: Complete multiuser documentation (user, admin, and API guides) Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Update multiuser documentation for single-user mode and CLI scripts - Document multiuser config option (true/false/absent) - Explain single-user mode behavior (no login required) - Document mode switching and legacy "system" user - Update user management to reference CLI scripts (useradd, userdel, usermod, userlist) - Note that web UI for user management is coming in future release - Add adaptive API client example for both modes Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * docs(multiuser): bring user guide documentation up to date --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * docs(app): update multiuser documentation * bugfix(app): fix misaligned database migration calls * chore(tests): update migration test to accommodate resequencing of migrations * fix(frontend): prevent caching of static pages * chore(backend): ruff * fix(backend): fix incorrect migration import * Fix: Admin users can see image previews from other users' generations (#61) * Initial plan * Fix: strip image preview from InvocationProgressEvent sent to admin room Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore: ruff * fix(backend): add migration_29 file * chore(tests): fix migration_29 test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * fix(queue): System user queue items show blank instead of `` for non-admin users (#63) * Initial plan * fix(queue): System user queue items show blank instead of `` for non-admin users Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(backend): ruff --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Hide "Use Cache" checkbox in node editor for non-admin users in multiuser mode (#65) * Initial plan * Hide use cache checkbox for non-admin users in multiuser mode Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix node loading hang when invoke URL ends with /app (#67) * Initial plan * Fix node loading hang when URL ends with /app Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Move user management scripts to installable module with CLI entry points (#69) * Initial plan * Add user management module with invoke-useradd/userdel/userlist/usermod entry points Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(util): remove superceded user administration scripts --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * chore(backend): reorganized migrations, but something still broken * Fix migration 28 crash when `client_state.data` column is absent (#70) * Initial plan * Fix migration 28 to handle missing data column in client_state table Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Consolidate multiuser DB migrations 27–29 into a single migration step (#71) * Initial plan * Consolidate migrations 27, 28, and 29 into a single migration step Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Add `--root` option to user management CLI utilities (#81) * Initial plan * Add --root option to user management CLI utilities Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix queue clear() endpoint to respect user_id for multi-tenancy (#75) Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Add tests for session queue clear() user_id scoping Co-authored-by: lstein <111189+lstein@users.noreply.github.com> chore(frontend): rebuild typegen Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * fix: use AdminUserOrDefault for pause and resume queue endpoints (#77) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix: queue pause/resume buttons disabled in single-user mode (#83) In single-user mode, currentUser is never populated (no auth), so `currentUser?.is_admin ?? false` always returns false, disabling the buttons. Follow the same pattern as useIsModelManagerEnabled: treat as admin when multiuser mode is disabled, and check is_admin flag when enabled. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix: enforce board ownership checks in multiuser mode (#84) - get_board: verify current user owns the board (or is admin), return 403 otherwise - update_board: verify ownership before updating, 404 if not found, 403 if unauthorized - delete_board: verify ownership before deleting, 404 if not found, 403 if unauthorized - list_all_board_image_names: add CurrentUserOrDefault auth and ownership check for non-'none' board IDs test: add ownership enforcement tests for board endpoints in multiuser mode - Auth requirement tests for get, update, delete, and list_image_names - Cross-user 403 forbidden tests (non-owner cannot access/modify/delete) - Admin bypass tests (admin can access/update/delete any user's board) - Board listing isolation test (users only see their own boards) - Refactored fixtures to use monkeypatch (consistent with other test files) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix: Clear auth state when switching from multiuser to single-user mode (#86) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix race conditions in download queue and model install service (#98) * Initial plan * Fix race conditions in download queue and model install service Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Weblate (bot) Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com> --- Makefile | 17 +- USER_ISOLATION_IMPLEMENTATION.md | 169 +++ docs/installation/requirements.md | 4 +- docs/multiuser/admin_guide.md | 876 ++++++++++++ docs/multiuser/api_guide.md | 1224 +++++++++++++++++ docs/multiuser/specification.md | 870 ++++++++++++ docs/multiuser/user_guide.md | 489 +++++++ invokeai/app/api/auth_dependencies.py | 166 +++ invokeai/app/api/dependencies.py | 11 + invokeai/app/api/no_cache_staticfiles.py | 22 + invokeai/app/api/routers/auth.py | 248 ++++ invokeai/app/api/routers/boards.py | 58 +- invokeai/app/api/routers/client_state.py | 24 +- invokeai/app/api/routers/images.py | 22 +- invokeai/app/api/routers/model_manager.py | 22 +- invokeai/app/api/routers/session_queue.py | 170 ++- invokeai/app/api/sockets.py | 145 +- invokeai/app/api_app.py | 3 + .../app/services/app_settings/__init__.py | 5 + .../app_settings/app_settings_service.py | 74 + invokeai/app/services/auth/__init__.py | 1 + invokeai/app/services/auth/password_utils.py | 86 ++ invokeai/app/services/auth/token_service.py | 105 ++ .../board_records/board_records_base.py | 16 +- .../board_records/board_records_common.py | 5 + .../board_records/board_records_sqlite.py | 148 +- invokeai/app/services/boards/boards_base.py | 16 +- invokeai/app/services/boards/boards_common.py | 9 +- .../app/services/boards/boards_default.py | 38 +- .../client_state_persistence_base.py | 15 +- .../client_state_persistence_sqlite.py | 64 +- .../app/services/config/config_default.py | 4 + .../app/services/download/download_default.py | 25 +- invokeai/app/services/events/events_common.py | 6 + .../image_records/image_records_base.py | 7 +- .../image_records/image_records_sqlite.py | 25 +- invokeai/app/services/images/images_base.py | 5 + .../app/services/images/images_default.py | 10 + invokeai/app/services/invocation_services.py | 3 + .../model_install/model_install_default.py | 9 + .../session_queue/session_queue_base.py | 44 +- .../session_queue/session_queue_common.py | 20 +- .../session_queue/session_queue_sqlite.py | 255 +++- .../app/services/shared/invocation_context.py | 13 +- .../app/services/shared/sqlite/sqlite_util.py | 2 + .../migrations/migration_27.py | 366 +++++ invokeai/app/services/users/__init__.py | 1 + invokeai/app/services/users/users_base.py | 126 ++ invokeai/app/services/users/users_common.py | 114 ++ invokeai/app/services/users/users_default.py | 251 ++++ invokeai/app/util/user_management.py | 579 ++++++++ invokeai/frontend/web/knip.ts | 3 + invokeai/frontend/web/openapi.json | 93 ++ invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 45 + invokeai/frontend/web/public/locales/en.json | 44 + .../frontend/web/src/app/components/App.tsx | 70 +- .../web/src/app/components/InvokeAIUI.tsx | 9 +- .../store/enhancers/reduxRemember/driver.ts | 29 +- invokeai/frontend/web/src/app/store/store.ts | 3 + .../auth/components/AdministratorSetup.tsx | 246 ++++ .../features/auth/components/LoginPage.tsx | 168 +++ .../auth/components/ProtectedRoute.tsx | 100 ++ .../src/features/auth/components/UserMenu.tsx | 71 + .../web/src/features/auth/store/authSlice.ts | 83 ++ .../controlLayers/store/paramsSlice.ts | 7 + .../Boards/BoardsList/GalleryBoard.tsx | 11 +- .../features/gallery/store/gallerySlice.ts | 9 + .../hooks/useIsModelManagerEnabled.ts | 29 + .../hooks/useStarterModelsToast.tsx | 10 +- .../modelManagerV2/subpanels/ModelManager.tsx | 4 +- .../ModelListBulkActions.tsx | 38 +- .../modelManagerV2/subpanels/ModelPane.tsx | 36 +- .../ControlAdapterModelDefaultSettings.tsx | 24 +- .../LoRAModelDefaultSettings.tsx | 24 +- .../MainModelDefaultSettings.tsx | 24 +- .../subpanels/ModelPanel/ModelHeader.tsx | 5 +- .../subpanels/ModelPanel/ModelView.tsx | 12 +- .../nodes/Invocation/UseCacheCheckbox.tsx | 23 +- .../queue/components/QueueCountBadge.tsx | 56 +- .../QueueList/QueueItemComponent.tsx | 78 +- .../components/QueueList/QueueListHeader.tsx | 1 + .../queue/components/QueueList/constants.ts | 4 + .../useCancelAllExceptCurrentQueueItem.ts | 6 +- .../queue/hooks/useCancelCurrentQueueItem.ts | 23 +- .../queue/hooks/useCancelQueueItem.ts | 6 +- .../hooks/useCancelQueueItemsByDestination.ts | 6 +- .../src/features/queue/hooks/useClearQueue.ts | 6 +- .../useDeleteAllExceptCurrentQueueItem.ts | 6 +- .../queue/hooks/useDeleteQueueItem.ts | 6 +- .../features/queue/hooks/usePauseProcessor.ts | 17 +- .../queue/hooks/useResumeProcessor.ts | 17 +- .../features/ui/components/VerticalNavBar.tsx | 2 + invokeai/frontend/web/src/i18n.ts | 2 +- .../web/src/services/api/endpoints/appInfo.ts | 2 +- .../web/src/services/api/endpoints/auth.ts | 74 + .../web/src/services/api/endpoints/queue.ts | 3 + .../frontend/web/src/services/api/index.ts | 16 +- .../frontend/web/src/services/api/schema.ts | 550 +++++++- .../src/services/events/setEventListeners.tsx | 54 +- .../web/src/services/events/useSocketIO.ts | 9 +- mkdocs.yml | 13 +- pyproject.toml | 8 + tests/app/routers/test_auth.py | 336 +++++ tests/app/routers/test_boards_multiuser.py | 459 +++++++ .../routers/test_client_state_multiuser.py | 299 ++++ .../test_session_queue_sanitization.py | 159 +++ tests/app/services/auth/__init__.py | 1 + .../app/services/auth/test_data_isolation.py | 411 ++++++ .../app/services/auth/test_password_utils.py | 272 ++++ tests/app/services/auth/test_performance.py | 474 +++++++ tests/app/services/auth/test_security.py | 459 +++++++ tests/app/services/auth/test_token_service.py | 371 +++++ .../bulk_download/test_bulk_download.py | 14 +- .../session_queue/test_session_queue_clear.py | 106 ++ .../app/services/users/test_password_utils.py | 56 + .../app/services/users/test_token_service.py | 43 + tests/app/services/users/test_user_service.py | 259 ++++ tests/conftest.py | 11 +- tests/test_sqlite_migrator.py | 172 +++ uv.lock | 110 +- 121 files changed, 12776 insertions(+), 409 deletions(-) create mode 100644 USER_ISOLATION_IMPLEMENTATION.md create mode 100644 docs/multiuser/admin_guide.md create mode 100644 docs/multiuser/api_guide.md create mode 100644 docs/multiuser/specification.md create mode 100644 docs/multiuser/user_guide.md create mode 100644 invokeai/app/api/auth_dependencies.py create mode 100644 invokeai/app/api/routers/auth.py create mode 100644 invokeai/app/services/app_settings/__init__.py create mode 100644 invokeai/app/services/app_settings/app_settings_service.py create mode 100644 invokeai/app/services/auth/__init__.py create mode 100644 invokeai/app/services/auth/password_utils.py create mode 100644 invokeai/app/services/auth/token_service.py create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py create mode 100644 invokeai/app/services/users/__init__.py create mode 100644 invokeai/app/services/users/users_base.py create mode 100644 invokeai/app/services/users/users_common.py create mode 100644 invokeai/app/services/users/users_default.py create mode 100644 invokeai/app/util/user_management.py create mode 100644 invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx create mode 100644 invokeai/frontend/web/src/features/auth/components/LoginPage.tsx create mode 100644 invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx create mode 100644 invokeai/frontend/web/src/features/auth/components/UserMenu.tsx create mode 100644 invokeai/frontend/web/src/features/auth/store/authSlice.ts create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/hooks/useIsModelManagerEnabled.ts create mode 100644 invokeai/frontend/web/src/services/api/endpoints/auth.ts create mode 100644 tests/app/routers/test_auth.py create mode 100644 tests/app/routers/test_boards_multiuser.py create mode 100644 tests/app/routers/test_client_state_multiuser.py create mode 100644 tests/app/routers/test_session_queue_sanitization.py create mode 100644 tests/app/services/auth/__init__.py create mode 100644 tests/app/services/auth/test_data_isolation.py create mode 100644 tests/app/services/auth/test_password_utils.py create mode 100644 tests/app/services/auth/test_performance.py create mode 100644 tests/app/services/auth/test_security.py create mode 100644 tests/app/services/auth/test_token_service.py create mode 100644 tests/app/services/session_queue/test_session_queue_clear.py create mode 100644 tests/app/services/users/test_password_utils.py create mode 100644 tests/app/services/users/test_token_service.py create mode 100644 tests/app/services/users/test_user_service.py diff --git a/Makefile b/Makefile index c19dd97038..f1e81429e7 100644 --- a/Makefile +++ b/Makefile @@ -16,20 +16,20 @@ help: @echo "frontend-build Build the frontend in order to run on localhost:9090" @echo "frontend-dev Run the frontend in developer mode on localhost:5173" @echo "frontend-typegen Generate types for the frontend from the OpenAPI schema" - @echo "wheel Build the wheel for the current version" + @echo "frontend-prettier Format the frontend using lint:prettier" + @echo "wheel Build the wheel for the current version" @echo "tag-release Tag the GitHub repository with the current version (use at release time only!)" @echo "openapi Generate the OpenAPI schema for the app, outputting to stdout" @echo "docs Serve the mkdocs site with live reload" # Runs ruff, fixing any safely-fixable errors and formatting ruff: - ruff check . --fix - ruff 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: @@ -64,6 +64,13 @@ frontend-dev: frontend-typegen: cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen +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: cd scripts && ./build_wheel.sh @@ -79,4 +86,4 @@ openapi: # Serve the mkdocs site w/ live reload .PHONY: docs docs: - mkdocs serve \ No newline at end of file + mkdocs serve diff --git a/USER_ISOLATION_IMPLEMENTATION.md b/USER_ISOLATION_IMPLEMENTATION.md new file mode 100644 index 0000000000..324c40db56 --- /dev/null +++ b/USER_ISOLATION_IMPLEMENTATION.md @@ -0,0 +1,169 @@ +# User Isolation Implementation Summary + +This document describes the implementation of user isolation features in the InvokeAI session queue and processing system to address issues identified in the enhancement request. + +## Issues Addressed + +### 1. Cross-User Image/Preview Visibility +**Problem:** When two users are logged in simultaneously and one initiates a generation, the generation preview shows up in both users' browsers and the generated image gets saved to both users' image boards. + +**Solution:** Implemented socket-level event filtering based on user authentication: + +#### Backend Changes (`invokeai/app/api/sockets.py`): +- Added socket authentication middleware in `_handle_connect()` method +- Extracts JWT token from socket auth data or HTTP headers +- Verifies token using existing `verify_token()` function +- Stores `user_id` and `is_admin` in socket session for later use +- Modified `_handle_queue_event()` to filter events by user: + - For `QueueItemEventBase` events, only emit to: + - The user who owns the queue item (`user_id` matches) + - Admin users (`is_admin` is True) + - For general queue events, emit to all subscribers + +#### Event System Changes (`invokeai/app/services/events/events_common.py`): +- Added `user_id` field to `QueueItemEventBase` class +- Updated all event builders to include `user_id` from queue items: + - `InvocationStartedEvent.build()` + - `InvocationProgressEvent.build()` + - `InvocationCompleteEvent.build()` + - `InvocationErrorEvent.build()` + - `QueueItemStatusChangedEvent.build()` + +### 2. Batch Field Values Privacy +**Problem:** Users can see batch field values from generation processes launched by other users. + +**Solution:** Implemented field value sanitization at the API level: + +#### API Router Changes (`invokeai/app/api/routers/session_queue.py`): +- Created `sanitize_queue_item_for_user()` helper function + - Clears `field_values` for non-admin users viewing other users' items + - Admins and item owners can see all field values +- Updated endpoints to require authentication and sanitize responses: + - `list_all_queue_items()` - Added `CurrentUser` dependency + - `get_queue_items_by_item_ids()` - Added `CurrentUser` dependency + - `get_queue_item()` - Added `CurrentUser` dependency + +### 3. Queue Updates Across Browser Windows +**Problem:** When the job queue tab is open in multiple browsers and a generation is begun in one browser window, the queue does not update in the other window. + +**Status:** This issue is likely resolved by the socket authentication and event filtering changes. The existing socket subscription mechanism (`subscribe_queue` event) already supports multiple connections per user. Testing is required to confirm this works correctly with the new authentication flow. + +### 4. User Information Display +**Problem:** Queue table lacks user identification, making it difficult to know who launched which job. + +**Solution:** Added user information to queue items and UI: + +#### Database Layer (`invokeai/app/services/session_queue/session_queue_sqlite.py`): +- Updated SQL queries to JOIN with `users` table +- Modified methods to fetch user information: + - `get_queue_item()` - Now selects `display_name` and `email` from users table + - `dequeue()` - Includes user info + - `get_next()` - Includes user info + - `get_current()` - Includes user info + - `list_all_queue_items()` - Includes user info + +#### Data Model Changes (`invokeai/app/services/session_queue/session_queue_common.py`): +- Added optional fields to `SessionQueueItem`: + - `user_display_name: Optional[str]` - Display name from users table + - `user_email: Optional[str]` - Email from users table + - Note: `user_id` field already existed from Migration 25 + +#### Frontend UI Changes: +- **Constants** (`constants.ts`): Added `user: '8rem'` column width +- **Header** (`QueueListHeader.tsx`): Added "User" column header +- **Item Component** (`QueueItemComponent.tsx`): + - Added logic to display user information (display_name → email → user_id) + - Added user column to queue item row + - Added tooltip with full username on hover + - Added "Hidden for privacy" message when field_values are null for non-owned items +- **Localization** (`en.json`): Added translations: + - `"user": "User"` + - `"fieldValuesHidden": "Hidden for privacy"` + +## Security Considerations + +### Token Verification +- Tokens are verified using the existing `verify_token()` function from `invokeai.app.services.auth.token_service` +- Invalid or missing tokens default to "system" user with non-admin privileges +- Socket connections without valid tokens are still accepted for backward compatibility but have limited access + +### Data Privacy +- Field values are only visible to: + - The user who created the queue item + - Admin users +- Non-admin users viewing other users' queue items see "Hidden for privacy" instead of field values + +### Admin Privileges +- Admin users can see all queue events and field values across all users +- Admin status is determined from the JWT token's `is_admin` field + +## Migration Notes + +No database migration is required. The changes leverage: +- Existing `user_id` column in `session_queue` table (added in Migration 25) +- Existing `users` table (added in Migration 25) +- SQL LEFT JOINs to fetch user information (gracefully handles missing user records) + +## Testing Requirements + +### Backend Testing +1. **Socket Authentication:** + - Verify valid tokens are accepted and user context is stored + - Verify invalid tokens default to system user + - Verify expired tokens are rejected + +2. **Event Filtering:** + - User A should only receive events for their own queue items + - Admin users should receive all events + - Non-admin users should not receive events from other users + +3. **Field Value Sanitization:** + - Non-admin users should see null field_values for other users' items + - Admins should see all field values + - Users should see their own field values + +### Frontend Testing +1. **UI Display:** + - User column should display in queue list + - Display name should be shown when available + - Email should be shown as fallback when display name is missing + - User ID should be shown when both display name and email are missing + - Tooltip should show full username on hover + +2. **Field Values Display:** + - "Hidden for privacy" message should appear when viewing other users' items + - Own items should show field values normally + +3. **Multi-Browser Testing:** + - Open queue tab in two browsers with different users + - Start generation in one browser + - Verify other browser doesn't see the preview/progress + - Verify admin user can see all generations + +### Integration Testing +1. Multi-user scenarios with simultaneous generations +2. Queue updates across multiple browser windows +3. Admin vs. non-admin privilege differentiation +4. Socket reconnection handling + +## Known Limitations + +1. **TypeScript Types:** + - The OpenAPI schema needs to be regenerated to include new fields + - Run: `cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen` + +2. **Backward Compatibility:** + - System user ("system") entries will not have display name or email + - Existing queue items from before Migration 25 will have user_id="system" + +3. **Socket.IO Session Storage:** + - Socket.IO's in-memory session storage may not persist across server restarts + - Consider implementing persistent session storage if needed for production + +## Future Enhancements + +1. Add user filtering to queue list (show only my items vs. all items) +2. Add permission system for queue management operations (cancel, retry, delete) +3. Implement queue item ownership transfer for administrative purposes +4. Add audit logging for queue operations with user attribution +5. Consider implementing user-specific queue limits or quotas diff --git a/docs/installation/requirements.md b/docs/installation/requirements.md index 7fcdd14b52..b120eeadbc 100644 --- a/docs/installation/requirements.md +++ b/docs/installation/requirements.md @@ -6,7 +6,9 @@ Invoke runs on Windows 10+, macOS 14+ and Linux (Ubuntu 20.04+ is well-tested). Hardware requirements vary significantly depending on model and image output size. -The requirements below are rough guidelines for best performance. GPUs with less VRAM typically still work, if a bit slower. Follow the [Low-VRAM mode guide](./features/low-vram.md) to optimize performance. +The requirements below are rough guidelines for best performance. GPUs +with less VRAM typically still work, if a bit slower. Follow the +[Low-VRAM mode guide](../features/low-vram.md) to optimize performance. - All Apple Silicon (M1, M2, etc) Macs work, but 16GB+ memory is recommended. - AMD GPUs are supported on Linux only. The VRAM requirements are the same as Nvidia GPUs. diff --git a/docs/multiuser/admin_guide.md b/docs/multiuser/admin_guide.md new file mode 100644 index 0000000000..d0f797feab --- /dev/null +++ b/docs/multiuser/admin_guide.md @@ -0,0 +1,876 @@ +# InvokeAI Multi-User Administrator Guide + +## Overview + +This guide is for administrators managing a multi-user InvokeAI installation. It covers initial setup, user management, security best practices, and troubleshooting. + +## Prerequisites + +Before enabling multi-user support, ensure you have: + +- InvokeAI installed and running +- Access to the server filesystem (for initial setup) +- Understanding of your deployment environment +- Backup of your existing data (recommended) + +## Initial Setup + +### Activating Multiuser Mode + +To put InvokeAI into multiuser mode, you will need to add the option +`multiuser: true` to its configuration file. This file is located at +`INVOKEAI_ROOT/invokeai.yaml` With the InvokeAI backend halted, add +the new configuration option to the end of the file with a text editor +so that it looks like this: + +```yaml +# Internal metadata - do not edit: +schema_version: 4.0.2 + +# Enable/disable multi-user mode +multiuser: true +``` + +Then restart the InvokeAI server backend from the command line or +using the launcher. + +!!! note "Reverting to single-user mode" + If at any time you wish to revert to single-user mode, simply comment + out the `multiuser` line, or change "true" to "false". Then + restart the server. Because of the way that browsers cache pages, + users with open InvokeAI sessions may need to force-refresh their + browsers. + + +### First Administrator Account + +When InvokeAI starts for the first time in multi-user mode, you'll see the **Administrator Setup** dialog. + +**Setup Steps:** + +1. **Email Address**: Enter a valid email address (this becomes your username) + + * Example: `admin@example.com` or `admin@localhost` for testing + * Must be a valid email format + * Cannot be changed later without database access + +2. **Display Name**: Enter a friendly name + + * Example: "System Administrator" or your real name + * Can be changed later in your profile + * Visible to other users in shared contexts + +3. **Password**: Create a strong administrator password + + * **Minimum requirements:** + + * At least 8 characters long + * Contains uppercase letters (A-Z) + * Contains lowercase letters (a-z) + * Contains numbers (0-9) + + * **Recommended:** + + * Use 12+ characters + * Include special characters (!@#$%^&*) + * Use a password manager to generate and store + * Don't reuse passwords from other services + +4. **Confirm Password**: Re-enter the password + +5. Click **Create Administrator Account** + +!!! warning "Important" + Store these credentials securely! The + first administrator account can reset + the password to something new, but cannot + retrieve a lost one. + +### Configuration + +InvokeAI can run in single-user or multi-user mode, controlled by the `multiuser` configuration option in `invokeai.yaml`: + +```yaml +# Enable/disable multi-user mode +multiuser: true # Enable multi-user mode (requires authentication) +# multiuser: false # Single-user mode (no authentication required) +# If the multiuser option is absent, single-user mode is used + +# Database configuration +use_memory_db: false # Use persistent database +db_path: databases/invokeai.db # Database location + +# Session configuration (multi-user mode only) +jwt_secret_key: "your-secret-key-here" # Auto-generated if not specified +jwt_token_expiry_hours: 24 # Default session timeout +jwt_remember_me_days: 7 # "Remember me" duration +``` + +**Single-User Mode** (`multiuser: false` or option absent): +- No authentication required +- All functionality enabled by default +- All boards and images visible in unified view +- Ideal for personal use or trusted environments + +**Multi-User Mode** (`multiuser: true`): +- Authentication required for access +- User isolation for boards, images, and workflows +- Role-based permissions enforced +- Ideal for shared servers or team environments + +!!! warning "Mode Switching Behavior" + **Switching to Single-User Mode:** If boards or images were created in multi-user mode, they will all be combined into a single unified view when switching to single-user mode. + + **Switching to Multi-User Mode:** Legacy boards and images created under single-user mode will be owned by an internal user named "system." Only the Administrator will have access to these legacy assets. A utility to migrate these legacy assets to another user will be part of a future release. + +### Migration from Single-User + +When upgrading from a single-user installation or switching modes: + +1. **Automatic Migration**: The database will automatically migrate to multi-user schema when multi-user mode is first enabled +2. **Legacy Data Ownership**: Existing data (boards, images, workflows) created in single-user mode is assigned to an internal user named "system" +3. **Administrator Access**: Only administrators will have access to legacy "system"-owned assets when in multi-user mode +4. **No Data Loss**: All existing content is preserved + +**Migration Process:** + +```bash +# Backup your database first +cp databases/invokeai.db databases/invokeai.db.backup + +# Enable multi-user mode in invokeai.yaml +# multiuser: true + +# Start InvokeAI (migration happens automatically) +invokeai-web + +# Complete the administrator setup dialog +# Legacy data will be owned by "system" user +``` + +!!! note "Legacy Asset Migration" + A utility to migrate legacy "system"-owned assets to specific user accounts will be available in a future release. Until then, administrators can access and manage all legacy content. + +## User Management + +### Creating Users + +**Via Web Interface (Coming Soon):** + +!!! info "Web UI for User Management" + A web-based user interface that allows administrators to manage users is coming in a future release. Until then, use the command-line scripts described below. + +**Via Command Line Scripts:** + +InvokeAI provides several command-line scripts in the `scripts/` directory for user management: + +**useradd.py** - Add a new user: + +```bash +# Interactive mode (prompts for details) +python scripts/useradd.py + +# Create a regular user +python scripts/useradd.py \ + --email user@example.com \ + --password TempPass123 \ + --name "User Name" + +# Create an administrator +python scripts/useradd.py \ + --email admin@example.com \ + --password AdminPass123 \ + --name "Admin Name" \ + --admin +``` + +**userlist.py** - List all users: + +```bash +# List all users +python scripts/userlist.py + +# Show detailed information +python scripts/userlist.py --verbose +``` + +**usermod.py** - Modify an existing user: + +```bash +# Change display name +python scripts/usermod.py --email user@example.com --name "New Name" + +# Promote to administrator +python scripts/usermod.py --email user@example.com --admin + +# Demote from administrator +python scripts/usermod.py --email user@example.com --no-admin + +# Deactivate account +python scripts/usermod.py --email user@example.com --deactivate + +# Reactivate account +python scripts/usermod.py --email user@example.com --activate + +# Change password +python scripts/usermod.py --email user@example.com --password NewPassword123 +``` + +**userdel.py** - Delete a user: + +```bash +# Delete a user (prompts for confirmation) +python scripts/userdel.py --email user@example.com + +# Delete without confirmation +python scripts/userdel.py --email user@example.com --force +``` + +!!! tip "Script Usage" + Run any script with `--help` to see all available options: + ```bash + python scripts/useradd.py --help + ``` + +!!! warning "Command Line Management" + - These scripts directly modify the database + - Always backup your database before making changes + - Changes take effect immediately (users may need to log in again) + - Deleting a user permanently removes all their content + +### Editing Users + +**Via Command Line:** + +Use `usermod.py` as described above to modify user properties. + +!!! warning "Last Administrator" + You cannot remove admin privileges from the last remaining administrator account. + +### Resetting User Passwords + +**Via Web Interface (Coming Soon):** + +Web-based password reset functionality for administrators is coming in a future release. + +**Via Command Line:** + +```bash +# Reset a user's password +python scripts/usermod.py --email user@example.com --password NewTempPassword123 +``` + +**Security Note:** Never send passwords via email or unsecured channels. Use secure communication methods. + +### Deactivating Users + +**Via Command Line:** + +```bash +# Deactivate a user account +python scripts/usermod.py --email user@example.com --deactivate + +# Reactivate a user account +python scripts/usermod.py --email user@example.com --activate +``` + +**Effects:** + +- User cannot log in when deactivated +- Existing sessions are immediately invalidated +- User's data is preserved +- Can be reactivated at any time + +### Deleting Users + +**Via Command Line:** + +```bash +# Delete a user (prompts for confirmation) +python scripts/userdel.py --email user@example.com + +# Delete without confirmation prompt +python scripts/userdel.py --email user@example.com --force +``` + +**Important:** + +- ⚠️ This action is **permanent** +- User's boards, images, and workflows are deleted +- Cannot be undone +- Consider deactivating instead of deleting + +!!! warning "Data Loss" + Deleting a user permanently removes all their content. Back up the database first if recovery might be needed. + +### Viewing User Activity + +**Queue Management:** + +1. Navigate to **Admin** → **Queue Overview** +2. View all users' active and pending generations +3. Filter by user +4. Cancel stuck or problematic tasks + +**User Statistics:** + +- Number of boards created +- Number of images generated +- Storage usage (if enabled) +- Last login time + +## Model Management + +As an administrator, you have full access to model management. + +### Adding Models + +**Via Model Manager UI:** + +1. Go to **Models** tab +2. Click **Add Model** +3. Choose installation method: + - **From URL**: Provide HuggingFace repo or download URL + - **From Local Path**: Scan local directories + - **Import**: Import model from filesystem + +**Supported Model Types:** + +- Main models (Stable Diffusion, SDXL, FLUX) +- LoRA models +- ControlNet models +- VAE models +- Textual Inversions +- IP-Adapters + +### Configuring Models + +**Model Settings:** + +- Display name +- Description +- Default generation settings (CFG, steps, scheduler) +- Variant selection (fp16/fp32) +- Model thumbnail image + +**Default Settings:** + +Set default parameters that users will start with: + +1. Select a model +2. Go to **Default Settings** tab +3. Configure: + - CFG Scale + - Steps + - Scheduler + - VAE selection +4. Save settings + +### Removing Models + +1. Go to **Models** tab +2. Select model(s) to remove +3. Click **Delete** +4. Confirm deletion + +!!! warning "Impact" + Removing a model affects all users who may be using it in workflows or saved settings. + +## Shared Boards + +Shared boards enable collaboration between users while maintaining control. + +!!! note "Future Feature" + Board sharing will be implemented in a future release. + +### Creating Shared Boards + +1. Log in as administrator +2. Create a new board (or use existing board) +3. Right-click the board → **Share Board** +4. Add users and set permissions +5. Click **Save Sharing Settings** + +### Permission Levels + +| Level | View | Add Images | Edit/Delete | Manage Sharing | +|-------|------|------------|-------------|----------------| +| **Read** | ✅ | ❌ | ❌ | ❌ | +| **Write** | ✅ | ✅ | ✅ | ❌ | +| **Admin** | ✅ | ✅ | ✅ | ✅ | + +**Permission Recommendations:** + +- **Read**: For viewers who should see but not modify content +- **Write**: For active collaborators who add and organize images +- **Admin**: For trusted users who help manage the shared board + +### Managing Shared Boards + +**Add Users to Shared Board:** + +1. Right-click shared board → **Manage Sharing** +2. Click **Add User** +3. Select user from dropdown +4. Choose permission level +5. Save changes + +**Remove Users from Shared Board:** + +1. Right-click shared board → **Manage Sharing** +2. Find user in list +3. Click **Remove** +4. Confirm removal + +**Change User Permissions:** + +1. Right-click shared board → **Manage Sharing** +2. Find user in list +3. Change permission dropdown +4. Save changes + +### Shared Board Best Practices + +- Give meaningful names to shared boards +- Document the board's purpose in the description +- Assign minimum necessary permissions +- Regularly audit access lists +- Remove users who no longer need access + +## Security + +### Password Policies + +**Enforced Requirements:** + +- Minimum 8 characters +- Must contain uppercase letters +- Must contain lowercase letters +- Must contain numbers + +**Recommended Policies:** + +- Require 12+ character passwords +- Include special characters +- Implement password rotation every 90 days +- Prevent password reuse +- Use multi-factor authentication (when available) + +### Session Management + +**Session Security and Token Management:** + +This system uses stateless JWT tokens with HMAC signatures to +identify users after they provide their initial credentials. The +tokens will persist for 24 hours by default, or for 7 days if the user +clicks the "Remember me" checkbox at login. Expired tokens are +automatically rejected and the user will have to log in again. + +At the client side, tokens are stored in browser localStorage. Logging +out clears them. No server-side session storage is required. + +The tokens include the user's ID, email, and admin status, along with +an HMAC signature. + +### Secret Key Management + +**Important:** The JWT secret key must be kept confidential. + +To generate tokens, each InvokeAI instance has a distinct secret JWT key that must be +kept confidential. The key is stored in the `app_settings` table of +the InvokeAI database with in a field value named `jwt_secret`. + +The secret key is automatically generated during database creation or +migration. If you wish to change the key, you may generate a +replacement using either of these commands: + + +```bash +# Python +python -c "import secrets; print(secrets.token_urlsafe(32))" + +# OpenSSL +openssl rand -base64 32 +``` + +Then cut and paste the printed secret into this Sqlite3 command: + +```bash +sqlite3 INVOKE_ROOT/databases/invokeai.db 'update app_settings set value="THE_SECRET" where key="jwt_secret"' +``` + +(replace INVOKE_ROOT with your InvokeAI root directory and THE_SECRET +with the new secret). + +After this, restart the server. All logged in users will be logged out +and will need to provide their usernames and passwords again. + +### Hosting a Shared InvokeAI Instance + +The multiuser feature allows you to run an InvokeAI backend that can +be accessed by your friends and family across your home network. It is +also possible to host a backend that is accessible over the Internet. + +By default, InvokeAI runs on `localhost`, IP address `127.0.0.1`, +which is only accessible to browsers running on the same machine as +the backend. To make the backend accessible to any machine on your +home or work LAN, add the line `host: 0.0.0.0` to the InvokeAI +configuration file, usually stored at `INVOKE_ROOT/invokeai.yaml`. + +Here is a minimal example. + +```yaml +# Internal metadata - do not edit: +schema_version: 4.0.2 + +# Put user settings here - see https://invoke-ai.github.io/InvokeAI/configuration/: +multiuser: true +host: 0.0.0.0 +``` + +After relaunching the backend you will be able to reach the server +from other machines on the LAN using the server machine's IP address +or hostname and port 9090. + +#### Connecting to the Internet + +!!! warning "Use at your own risk" + The InvokeAI team has done its best to make the software free of + exploitable bugs, but the software has not undergone a rigorous security + audit or intrusion testing. Use at your own risk + +It is also possible to create a (semi) public server accessible from +the Internet. The details of how to do this depend very much on your +home or corporate router/firewall system and are beyond the scope of +this document. + +If you expose InvokeAI to the Internet, there are a number of +precautions to take. Here is a brief list of recommended network +security practices. + +**HTTPS Configuration:** + +For internet deployments, always use HTTPS: + +```yaml +# Use a reverse proxy like nginx or Traefik +# Example nginx configuration: + +server { + listen 443 ssl http2; + server_name invoke.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:9090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +**Firewall Rules:** + +It is best to restrict access to trusted networks and remote IP +addresses, or use a VPN to connect to your home network. Rate limit +connections to InvokeAI's authentication endpoint +`http://your.host:9090/login`. + +**Backup and Recovery:** + +It is a good idea to periodically backup your InvokeAI database, +images, and possibly models in the event of unauthorized use of a +publicly-accessible server. + +**Manual Backup:** + +```bash +# Stop InvokeAI +# Copy database file +cd INVOKE_ROOT +cp databases/invokeai.db databases/invokeai.db.$(date +%Y%m%d) + +# Or create compressed backup +tar -czf invokeai_backup_$(date +%Y%m%d).tar.gz databases/ +``` + +**Automated Backup Script:** + +```bash +#!/bin/bash +# backup_invokeai.sh + +INVOKE_ROOT="/path/to/invoke_root" +BACKUP_DIR="/path/to/backups" +DB_PATH="$INVOKE_ROOT/databases/invokeai.db" +DATE=$(date +%Y%m%d_%H%M%S) + +# Create backup directory +mkdir -p "$BACKUP_DIR" + +# Copy database +cp "$DB_PATH" "$BACKUP_DIR/invokeai_$DATE.db" + +# Keep only last 30 days +find "$BACKUP_DIR" -name "invokeai_*.db" -mtime +30 -delete + +echo "Backup completed: invokeai_$DATE.db" +``` + +**Schedule with cron:** + +```bash +# Edit crontab +crontab -e + +# Add daily backup at 2 AM +0 2 * * * /path/to/backup_invokeai.sh +``` + + + +```bash +# Stop InvokeAI +# Replace current database with backup +cd INVOKE_ROOT +cp databases/invokeai.db databases/invokeai.db.old # Save current +cp databases/invokeai_backup.db databases/invokeai.db + +# Restart InvokeAI +invokeai-web +``` + +**Disaster Recover - Complete System Backup:** + +Include these directories/files: + +- `databases/` - All database files +- `models/` - Installed models (if locally stored) +- `outputs/` - Generated images +- `invokeai.yaml` - Configuration file +- Any custom scripts or modifications + +**Recovery Process:** + +1. Install InvokeAI on new system +2. Restore configuration file +3. Restore database directory +4. Restore models and outputs +5. Verify file permissions +6. Start InvokeAI and test + +## Troubleshooting + +### User Cannot Login + +**Symptom:** User reports unable to log in + +**Diagnosis:** + +1. Verify account exists and is active + ```bash + sqlite3 databases/invokeai.db "SELECT * FROM users WHERE email = 'user@example.com';" + ``` + +2. Check password (have user try resetting) +3. Verify account is active (`is_active = 1`) +4. Check for account lockout (if implemented) + +**Solutions:** + +- Reset user password +- Reactivate disabled account +- Verify email address is correct +- Check system logs for auth errors + +### Database Locked Errors + +**Symptom:** "Database is locked" errors + +**Causes:** + +- Concurrent write operations +- Long-running transactions +- Backup process accessing database +- File system issues + +**Solutions:** + +```bash +# Check for locks +fuser databases/invokeai.db + +# Increase timeout (in config) +# Or switch to WAL mode: +sqlite3 databases/invokeai.db "PRAGMA journal_mode=WAL;" +``` + +### Forgotten Admin Password + +**Recovery Process:** + +1. Stop InvokeAI +2. Direct database access: + ```bash + sqlite3 databases/invokeai.db + ``` + +3. Reset admin password (requires password hash): + ```sql + -- Generate hash first using Python: + -- from passlib.context import CryptContext + -- pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + -- print(pwd_context.hash("NewPassword123")) + + UPDATE users + SET password_hash = '$2b$12$...' + WHERE email = 'admin@example.com'; + ``` + +4. Restart InvokeAI + +**Alternative:** Remove `jwt_secret_key` from config to trigger setup wizard (will create new admin). + +### Performance Issues + +**Symptom:** Slow generation or UI + +**Diagnosis:** + +1. Check active generation count +2. Review resource usage (CPU/GPU/RAM) +3. Check database size and performance +4. Review network latency + +**Solutions:** + +- Limit concurrent generations +- Increase hardware resources +- Optimize database (`VACUUM`, `ANALYZE`) +- Add indexes for slow queries +- Consider load balancing + +### Migration Failures + +**Symptom:** Database migration fails on upgrade + +**Prevention:** + +- Always backup before upgrading +- Test migration on copy of database +- Review migration logs + +**Recovery:** + +```bash +# Restore backup +cp databases/invokeai.db.backup databases/invokeai.db + +# Try migration again with verbose logging +invokeai-web --log-level DEBUG +``` + +## Configuration Reference + +### Complete Configuration Example for a Public Site + +```yaml +# invokeai.yaml - Multi-user configuration + +# Internal metadata - do not edit: +schema_version: 4.0.2 + +# Put user settings here +multiuser: true + +# Server +host: "0.0.0.0" +port: 9090 + +# Performance +enable_partial_loading: true +precision: float16 +pytorch_cuda_alloc_conf: "backend:cudaMallocAsync" +hashing_algorithm: blake3_multi +``` +## Frequently Asked Questions + +### How many users can InvokeAI support? + +The backend will support dozens of concurrent users. However, because +the image generation queue is single-threaded, image generation tasks +are processed on a first-come, first-serve basis. This means that a +user may have to wait for all the other users' image generation jobs +to complete before their generation job starts to execute. + +A future version of InvokeAI may support concurrent execution on +systems with multiple GPUs/graphics cards. + +### Can I integrate with existing authentication systems? + +OAuth2/OpenID Connect support is planned for a future release. Currently, InvokeAI uses its own authentication system. + +### How do I audit user actions? + +Full audit logging is planned for a future release. Currently, you can: + +- Monitor the generation queue +- Review database changes +- Check application logs + +### Can users have different model access? + +Not in the current release. All users can view and use all installed models. Per-user model access is a possible enhancement. + +### How do I handle user data when they leave? + +Best practice: + +1. Deactivate the account first +2. Transfer ownership of shared boards +3. After transition period, delete the account +4. Or keep the account deactivated for audit purposes + +### What's the licensing impact of multi-user mode? + +InvokeAI remains under its existing license. Multi-user mode does not change licensing terms. + +## Getting Help + +### Support Resources + +- **Documentation**: [InvokeAI Docs](https://invoke-ai.github.io/InvokeAI/) +- **Discord**: [Join Community](https://discord.gg/ZmtBAhwWhy) +- **GitHub Issues**: [Report Problems](https://github.com/invoke-ai/InvokeAI/issues) +- **User Guide**: [For Users](user_guide.md) +- **API Guide**: [For Developers](api_guide.md) + +### Reporting Issues + +When reporting administrator issues, include: + +- InvokeAI version +- Operating system and version +- Database size and user count +- Relevant log excerpts +- Steps to reproduce +- Expected vs actual behavior + +## Additional Resources + +- [User Guide](user_guide.md) - For end users +- [API Guide](api_guide.md) - For API consumers +- [Multiuser Specification](specification.md) - Technical details + +--- + +**Need additional assistance?** Visit the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy) or file an issue on [GitHub](https://github.com/invoke-ai/InvokeAI/issues). diff --git a/docs/multiuser/api_guide.md b/docs/multiuser/api_guide.md new file mode 100644 index 0000000000..e521e881e7 --- /dev/null +++ b/docs/multiuser/api_guide.md @@ -0,0 +1,1224 @@ +# InvokeAI Multi-User API Guide + +## Overview + +This guide explains how to interact with InvokeAI's API in both single-user and multi-user modes. The API behavior depends on the `multiuser` configuration setting. + +### Single-User vs Multi-User Mode + +**Single-User Mode** (`multiuser: false` or option absent): +- No authentication required +- All API endpoints accessible without tokens +- Direct API access like previous InvokeAI versions +- All content visible in unified view + +**Multi-User Mode** (`multiuser: true`): +- JWT token authentication required +- User-scoped access to resources +- Role-based authorization (admin vs regular user) +- Data isolation between users + +## Authentication (Multi-User Mode Only) + +### Authentication Flow + +When multi-user mode is enabled, all API endpoints (except `/api/v1/auth/setup` and `/api/v1/auth/login`) require authentication using JWT (JSON Web Token) bearer tokens. + +**Authentication Process:** + +1. **Obtain Token**: POST credentials to `/api/v1/auth/login` +2. **Store Token**: Save the JWT token securely +3. **Use Token**: Include token in `Authorization` header for all requests +4. **Refresh**: Re-authenticate when token expires + +!!! note "Single-User Mode" + When running in single-user mode (`multiuser: false`), authentication endpoints are not available and authentication headers are not required. + +### Login Endpoint + +**Endpoint:** `POST /api/v1/auth/login` + +**Request:** + +```json +{ + "email": "user@example.com", + "password": "SecurePassword123", + "remember_me": false +} +``` + +**Response (Success):** + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "user_id": "abc123", + "email": "user@example.com", + "display_name": "John Doe", + "is_admin": false, + "is_active": true, + "created_at": "2024-01-15T10:00:00Z" + }, + "expires_in": 86400 +} +``` + +**Response (Error):** + +```json +{ + "detail": "Incorrect email or password" +} +``` + +**Status Codes:** + +- `200 OK` - Authentication successful +- `401 Unauthorized` - Invalid credentials +- `403 Forbidden` - Account disabled +- `422 Unprocessable Entity` - Invalid request format + +### Using the Token + +Include the JWT token in the `Authorization` header with the `Bearer` scheme: + +**HTTP Header:** + +``` +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Example HTTP Request:** + +```http +GET /api/v1/boards HTTP/1.1 +Host: localhost:9090 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +Content-Type: application/json +``` + +### Token Expiration + +Tokens have a limited lifetime: + +- **Default**: 24 hours (86400 seconds) +- **Remember Me**: 7 days (604800 seconds) + +**Handling Expiration:** + +```python +import requests +import time + +def api_request(url, token, max_retries=1): + headers = {"Authorization": f"Bearer {token}"} + response = requests.get(url, headers=headers) + + if response.status_code == 401: # Token expired + # Re-authenticate and retry + new_token = login() + headers = {"Authorization": f"Bearer {new_token}"} + response = requests.get(url, headers=headers) + + return response +``` + +### Logout Endpoint + +**Endpoint:** `POST /api/v1/auth/logout` + +**Request:** + +```http +POST /api/v1/auth/logout HTTP/1.1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Response:** + +```json +{ + "success": true +} +``` + +**Note:** With JWT tokens, logout is primarily client-side (delete token). Server-side session invalidation may be added in future releases. + +## Code Examples + +### Python + +**Using `requests` library:** + +```python +import requests +import json + +class InvokeAIClient: + def __init__(self, base_url="http://localhost:9090"): + self.base_url = base_url + self.token = None + + def login(self, email, password, remember_me=False): + """Authenticate and store token.""" + url = f"{self.base_url}/api/v1/auth/login" + payload = { + "email": email, + "password": password, + "remember_me": remember_me + } + + response = requests.post(url, json=payload) + response.raise_for_status() + + data = response.json() + self.token = data["token"] + return data["user"] + + def _get_headers(self): + """Get headers with authentication token.""" + if not self.token: + raise Exception("Not authenticated. Call login() first.") + + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + def get_boards(self): + """Get user's boards.""" + url = f"{self.base_url}/api/v1/boards/" + response = requests.get(url, headers=self._get_headers()) + response.raise_for_status() + return response.json() + + def create_board(self, board_name): + """Create a new board.""" + url = f"{self.base_url}/api/v1/boards/" + payload = {"board_name": board_name} + + response = requests.post( + url, + json=payload, + headers=self._get_headers() + ) + response.raise_for_status() + return response.json() + + def logout(self): + """Logout and clear token.""" + url = f"{self.base_url}/api/v1/auth/logout" + response = requests.post(url, headers=self._get_headers()) + self.token = None + return response.json() + +# Usage +client = InvokeAIClient() +user = client.login("user@example.com", "SecurePassword123") +print(f"Logged in as: {user['display_name']}") + +boards = client.get_boards() +print(f"User has {len(boards['items'])} boards") + +new_board = client.create_board("My New Board") +print(f"Created board: {new_board['board_name']}") + +client.logout() +``` + +**Error Handling:** + +```python +import requests +from requests.exceptions import HTTPError + +def safe_api_call(client, method, *args, **kwargs): + """Make API call with error handling.""" + try: + func = getattr(client, method) + return func(*args, **kwargs) + + except HTTPError as e: + if e.response.status_code == 401: + print("Authentication failed or token expired") + # Re-authenticate + client.login(email, password) + # Retry + return func(*args, **kwargs) + elif e.response.status_code == 403: + print("Permission denied") + elif e.response.status_code == 404: + print("Resource not found") + else: + print(f"API error: {e.response.status_code}") + print(e.response.text) + + raise + +# Usage +try: + boards = safe_api_call(client, "get_boards") +except Exception as e: + print(f"Failed to get boards: {e}") +``` + +### JavaScript/TypeScript + +**Using `fetch` API:** + +```javascript +class InvokeAIClient { + constructor(baseUrl = 'http://localhost:9090') { + this.baseUrl = baseUrl; + this.token = null; + } + + async login(email, password, rememberMe = false) { + const response = await fetch(`${this.baseUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + password, + remember_me: rememberMe, + }), + }); + + if (!response.ok) { + throw new Error(`Login failed: ${response.statusText}`); + } + + const data = await response.json(); + this.token = data.token; + + // Store token in localStorage + localStorage.setItem('invokeai_token', data.token); + + return data.user; + } + + getHeaders() { + if (!this.token) { + throw new Error('Not authenticated. Call login() first.'); + } + + return { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }; + } + + async getBoards() { + const response = await fetch(`${this.baseUrl}/api/v1/boards/`, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to get boards: ${response.statusText}`); + } + + return response.json(); + } + + async createBoard(boardName) { + const response = await fetch(`${this.baseUrl}/api/v1/boards/`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ board_name: boardName }), + }); + + if (!response.ok) { + throw new Error(`Failed to create board: ${response.statusText}`); + } + + return response.json(); + } + + async logout() { + const response = await fetch(`${this.baseUrl}/api/v1/auth/logout`, { + method: 'POST', + headers: this.getHeaders(), + }); + + this.token = null; + localStorage.removeItem('invokeai_token'); + + return response.json(); + } +} + +// Usage +(async () => { + const client = new InvokeAIClient(); + + try { + const user = await client.login('user@example.com', 'SecurePassword123'); + console.log(`Logged in as: ${user.display_name}`); + + const boards = await client.getBoards(); + console.log(`User has ${boards.items.length} boards`); + + const newBoard = await client.createBoard('My New Board'); + console.log(`Created board: ${newBoard.board_name}`); + + await client.logout(); + } catch (error) { + console.error('Error:', error.message); + } +})(); +``` + +**TypeScript with Types:** + +```typescript +interface LoginRequest { + email: string; + password: string; + remember_me?: boolean; +} + +interface User { + user_id: string; + email: string; + display_name: string; + is_admin: boolean; + is_active: boolean; + created_at: string; +} + +interface LoginResponse { + token: string; + user: User; + expires_in: number; +} + +interface Board { + board_id: string; + board_name: string; + created_at: string; + updated_at: string; + deleted_at?: string; + cover_image_name?: string; +} + +class InvokeAIClient { + private baseUrl: string; + private token: string | null = null; + + constructor(baseUrl: string = 'http://localhost:9090') { + this.baseUrl = baseUrl; + } + + async login( + email: string, + password: string, + rememberMe: boolean = false + ): Promise { + const response = await fetch(`${this.baseUrl}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, remember_me: rememberMe }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Login failed'); + } + + const data: LoginResponse = await response.json(); + this.token = data.token; + return data.user; + } + + private getHeaders(): HeadersInit { + if (!this.token) { + throw new Error('Not authenticated'); + } + return { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json', + }; + } + + async getBoards(): Promise<{ items: Board[] }> { + const response = await fetch(`${this.baseUrl}/api/v1/boards/`, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error('Failed to get boards'); + } + + return response.json(); + } +} +``` + +### cURL + +**Login:** + +```bash +# Login and extract token +TOKEN=$(curl -X POST http://localhost:9090/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "SecurePassword123", + "remember_me": false + }' | jq -r '.token') + +echo "Token: $TOKEN" +``` + +**Get Boards:** + +```bash +curl -X GET http://localhost:9090/api/v1/boards/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" +``` + +**Create Board:** + +```bash +curl -X POST http://localhost:9090/api/v1/boards/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "board_name": "My API Board" + }' +``` + +**Generate Image:** + +```bash +curl -X POST http://localhost:9090/api/v1/sessions/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "A beautiful landscape", + "width": 512, + "height": 512, + "steps": 30 + }' +``` + +## API Endpoint Changes + +### Authentication Required + +All endpoints now require authentication except: + +- `POST /api/v1/auth/setup` - Initial admin setup +- `POST /api/v1/auth/login` - User login + +### User-Scoped Resources + +Resources are now filtered by the authenticated user: + +**Boards:** + +```python +# Before (single-user) +GET /api/v1/boards/ # Returns all boards + +# After (multi-user) +GET /api/v1/boards/ # Returns only current user's boards +``` + +**Images:** + +```python +# Images are filtered by board ownership +GET /api/v1/images/ # Only shows images on user's boards +``` + +**Workflows:** + +```python +# Returns user's workflows + public workflows +GET /api/v1/workflows/ +``` + +**Queue:** + +```python +# Regular users see only their queue items +GET /api/v1/queue/ # User's queue items + +# Administrators see all queue items +GET /api/v1/queue/ # All users' queue items +``` + +### Administrator Endpoints + +Some endpoints require administrator privileges: + +**User Management:** + +```python +GET /api/v1/users # List users (admin only) +POST /api/v1/users # Create user (admin only) +GET /api/v1/users/{id} # Get user (admin only) +PATCH /api/v1/users/{id} # Update user (admin only) +DELETE /api/v1/users/{id} # Delete user (admin only) +``` + +**Model Management (Write Operations):** + +```python +POST /api/v1/models/install # Install model (admin only) +DELETE /api/v1/models/i/{key} # Delete model (admin only) +PATCH /api/v1/models/i/{key} # Update model (admin only) +PUT /api/v1/models/convert/{key} # Convert model (admin only) +``` + +**Model Management (Read Operations):** + +```python +GET /api/v1/models/ # List models (all users) +GET /api/v1/models/i/{key} # Get model details (all users) +``` + +### Error Responses + +**401 Unauthorized:** + +```json +{ + "detail": "Invalid authentication credentials" +} +``` + +Occurs when: + +- Token is missing +- Token is invalid +- Token is expired +- Token signature is invalid + +**403 Forbidden:** + +```json +{ + "detail": "Admin privileges required" +} +``` + +Occurs when: + +- User attempts admin-only operation +- Account is disabled +- Insufficient permissions + +**404 Not Found:** + +```json +{ + "detail": "Resource not found" +} +``` + +Occurs when: + +- Resource doesn't exist +- User doesn't have access to resource + +## New API Endpoints + +### Authentication Endpoints + +#### Setup Administrator + +**Endpoint:** `POST /api/v1/auth/setup` + +**Description:** Create initial administrator account (only works if no admin exists) + +**Request:** + +```json +{ + "email": "admin@example.com", + "display_name": "Administrator", + "password": "SecureAdminPass123" +} +``` + +**Response:** + +```json +{ + "success": true, + "user": { + "user_id": "abc123", + "email": "admin@example.com", + "display_name": "Administrator", + "is_admin": true, + "is_active": true + } +} +``` + +#### Get Current User + +**Endpoint:** `GET /api/v1/auth/me` + +**Description:** Get currently authenticated user's information + +**Request:** + +```http +GET /api/v1/auth/me +Authorization: Bearer +``` + +**Response:** + +```json +{ + "user_id": "abc123", + "email": "user@example.com", + "display_name": "John Doe", + "is_admin": false, + "is_active": true, + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-15T10:00:00Z", + "last_login_at": "2024-01-15T15:30:00Z" +} +``` + +#### Change Password + +**Endpoint:** `POST /api/v1/auth/change-password` + +**Description:** Change current user's password + +**Request:** + +```json +{ + "current_password": "OldPassword123", + "new_password": "NewPassword456" +} +``` + +**Response:** + +```json +{ + "success": true +} +``` + +### User Management Endpoints (Admin Only) + +#### List Users + +**Endpoint:** `GET /api/v1/users` + +**Request:** + +```http +GET /api/v1/users?page=1&per_page=20 +Authorization: Bearer +``` + +**Response:** + +```json +{ + "items": [ + { + "user_id": "abc123", + "email": "user@example.com", + "display_name": "John Doe", + "is_admin": false, + "is_active": true, + "created_at": "2024-01-15T10:00:00Z", + "last_login_at": "2024-01-15T15:30:00Z" + } + ], + "page": 1, + "pages": 1, + "per_page": 20, + "total": 5 +} +``` + +#### Create User + +**Endpoint:** `POST /api/v1/users` + +**Request:** + +```json +{ + "email": "newuser@example.com", + "display_name": "New User", + "password": "TempPassword123", + "is_admin": false +} +``` + +**Response:** + +```json +{ + "user_id": "xyz789", + "email": "newuser@example.com", + "display_name": "New User", + "is_admin": false, + "is_active": true, + "created_at": "2024-01-15T16:00:00Z" +} +``` + +#### Update User + +**Endpoint:** `PATCH /api/v1/users/{user_id}` + +**Request:** + +```json +{ + "display_name": "Updated Name", + "is_active": true, + "is_admin": false +} +``` + +**Response:** + +```json +{ + "user_id": "xyz789", + "email": "newuser@example.com", + "display_name": "Updated Name", + "is_admin": false, + "is_active": true +} +``` + +#### Delete User + +**Endpoint:** `DELETE /api/v1/users/{user_id}` + +**Response:** + +```json +{ + "success": true +} +``` + +#### Reset User Password + +**Endpoint:** `POST /api/v1/users/{user_id}/reset-password` + +**Request:** + +```json +{ + "new_password": "NewTempPass123" +} +``` + +**Response:** + +```json +{ + "success": true +} +``` + +### Board Sharing Endpoints + +#### Share Board + +**Endpoint:** `POST /api/v1/boards/{board_id}/share` + +**Request:** + +```json +{ + "user_id": "user123", + "permission": "write" +} +``` + +**Response:** + +```json +{ + "success": true, + "share": { + "board_id": "board456", + "user_id": "user123", + "permission": "write", + "shared_at": "2024-01-15T16:00:00Z" + } +} +``` + +#### List Board Shares + +**Endpoint:** `GET /api/v1/boards/{board_id}/shares` + +**Response:** + +```json +{ + "items": [ + { + "user_id": "user123", + "display_name": "John Doe", + "permission": "write", + "shared_at": "2024-01-15T16:00:00Z" + } + ] +} +``` + +#### Remove Board Share + +**Endpoint:** `DELETE /api/v1/boards/{board_id}/share/{user_id}` + +**Response:** + +```json +{ + "success": true +} +``` + +## Best Practices + +### Token Storage + +**Do:** + +- Store tokens securely (keychain, secure storage) +- Use HTTPS to transmit tokens +- Clear tokens on logout +- Handle token expiration gracefully + +**Don't:** + +- Store tokens in URL parameters +- Log tokens in plain text +- Share tokens between users +- Store tokens in version control + +### Error Handling + +Always handle authentication errors: + +```python +def make_request(client, func, *args, **kwargs): + max_retries = 3 + retry_count = 0 + + while retry_count < max_retries: + try: + return func(*args, **kwargs) + except AuthenticationError: + if retry_count >= max_retries - 1: + raise + # Re-authenticate + client.login(email, password) + retry_count += 1 + except Exception as e: + logger.error(f"Request failed: {e}") + raise +``` + +### Rate Limiting + +Be mindful of API rate limits: + +- Implement exponential backoff for retries +- Cache frequently accessed data +- Batch requests when possible +- Don't hammer the login endpoint + +### Connection Management + +```python +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +def create_session(): + """Create session with retry logic.""" + session = requests.Session() + + retry = Retry( + total=3, + backoff_factor=0.3, + status_forcelist=[500, 502, 503, 504], + ) + + adapter = HTTPAdapter(max_retries=retry) + session.mount('http://', adapter) + session.mount('https://', adapter) + + return session +``` + +## Migration Guide + +### Updating Existing Code + +**Before (single-user mode):** + +```python +import requests + +def get_boards(): + response = requests.get("http://localhost:9090/api/v1/boards/") + return response.json() +``` + +**After (multi-user mode):** + +```python +import requests + +class APIClient: + def __init__(self): + self.token = None + + def login(self, email, password): + response = requests.post( + "http://localhost:9090/api/v1/auth/login", + json={"email": email, "password": password} + ) + self.token = response.json()["token"] + + def get_boards(self): + headers = {"Authorization": f"Bearer {self.token}"} + response = requests.get( + "http://localhost:9090/api/v1/boards/", + headers=headers + ) + return response.json() + +# Usage +client = APIClient() +client.login("user@example.com", "password") +boards = client.get_boards() +``` + +### Backward Compatibility + +InvokeAI supports both single-user and multi-user modes via the `multiuser` configuration option. + +**Configuration:** + +```yaml +# invokeai.yaml + +# Single-user mode (no authentication) +multiuser: false # or omit the option entirely + +# Multi-user mode (authentication required) +multiuser: true +``` + +**Checking Mode Programmatically:** + +```python +def is_multiuser_enabled(base_url): + """Check if multi-user mode is enabled (authentication required).""" + response = requests.get(f"{base_url}/api/v1/boards/") + return response.status_code == 401 # 401 = auth required + +# Example usage +base_url = "http://localhost:9090" +if is_multiuser_enabled(base_url): + print("Multi-user mode: authentication required") + # Use authenticated API calls +else: + print("Single-user mode: no authentication needed") + # Use direct API calls +``` + +**Adaptive Client:** + +```python +class AdaptiveInvokeAIClient: + def __init__(self, base_url="http://localhost:9090"): + self.base_url = base_url + self.token = None + self.multiuser_mode = self._check_multiuser_mode() + + def _check_multiuser_mode(self): + """Detect if multi-user mode is enabled.""" + try: + response = requests.get(f"{self.base_url}/api/v1/boards/") + return response.status_code == 401 + except: + return False + + def login(self, email, password): + """Login (only needed in multi-user mode).""" + if not self.multiuser_mode: + print("Single-user mode: login not required") + return + + response = requests.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": email, "password": password} + ) + self.token = response.json()["token"] + + def _get_headers(self): + """Get headers (with auth token if in multi-user mode).""" + if self.multiuser_mode and self.token: + return {"Authorization": f"Bearer {self.token}"} + return {} + + def get_boards(self): + """Get boards (works in both modes).""" + response = requests.get( + f"{self.base_url}/api/v1/boards/", + headers=self._get_headers() + ) + return response.json() +``` + +## OpenAPI/Swagger Documentation + +InvokeAI provides OpenAPI documentation for all endpoints. + +**Access Swagger UI:** + +``` +http://localhost:9090/docs +``` + +**Download OpenAPI Schema:** + +```bash +curl http://localhost:9090/openapi.json > invokeai_openapi.json +``` + +**Generate Client Code:** + +Use tools like `openapi-generator` to generate client libraries: + +```bash +# Generate Python client +openapi-generator generate \ + -i http://localhost:9090/openapi.json \ + -g python \ + -o ./invokeai-client + +# Generate TypeScript client +openapi-generator generate \ + -i http://localhost:9090/openapi.json \ + -g typescript-fetch \ + -o ./invokeai-client-ts +``` + +## Security Considerations + +### HTTPS + +Always use HTTPS in production: + +```python +# Development +client = InvokeAIClient("http://localhost:9090") + +# Production +client = InvokeAIClient("https://invoke.example.com") +``` + +### Token Security + +Protect JWT tokens: + +```python +# Never log tokens +logger.info(f"User logged in") # Good +logger.info(f"Token: {token}") # Bad! + +# Use environment variables for credentials +import os +email = os.environ.get("INVOKEAI_EMAIL") +password = os.environ.get("INVOKEAI_PASSWORD") +``` + +### Input Validation + +Always validate user input: + +```python +import re + +def validate_email(email): + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return re.match(pattern, email) is not None + +def validate_password(password): + """Check password meets requirements.""" + if len(password) < 8: + return False, "Password must be at least 8 characters" + if not any(c.isupper() for c in password): + return False, "Password must contain uppercase letters" + if not any(c.islower() for c in password): + return False, "Password must contain lowercase letters" + if not any(c.isdigit() for c in password): + return False, "Password must contain numbers" + return True, "" +``` + +## Troubleshooting + +### Common Issues + +**Issue: "Invalid authentication credentials"** + +- Token expired - re-authenticate +- Token malformed - check token string +- Token signature invalid - check secret key hasn't changed + +**Issue: "Admin privileges required"** + +- User is not an administrator +- Use admin account for this operation + +**Issue: Token not being sent** + +- Check `Authorization` header is present +- Verify `Bearer` prefix is included +- Check token isn't truncated + +**Issue: CORS errors** + +Configure CORS in InvokeAI: + +```yaml +# invokeai.yaml +cors_origins: + - "http://localhost:3000" + - "https://myapp.example.com" +``` + +## Additional Resources + +- [User Guide](user_guide.md) - For end users +- [Administrator Guide](admin_guide.md) - For administrators +- [Multiuser Specification](specification.md) - Technical details +- [OpenAPI Documentation](http://localhost:9090/docs) - Interactive API docs +- [GitHub Repository](https://github.com/invoke-ai/InvokeAI) - Source code + +--- + +**Questions?** Visit the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy) or check the [FAQ](../faq.md). diff --git a/docs/multiuser/specification.md b/docs/multiuser/specification.md new file mode 100644 index 0000000000..e3a8528a2b --- /dev/null +++ b/docs/multiuser/specification.md @@ -0,0 +1,870 @@ +# InvokeAI Multi-User Support - Detailed Specification + +## 1. Executive Summary + +This document provides a comprehensive specification for adding multi-user support to InvokeAI. The feature will enable a single InvokeAI instance to support multiple isolated users, each with their own generation settings, image boards, and workflows, while maintaining administrative controls for model management and system configuration. + +## 2. Overview + +### 2.1 Goals +- Enable multiple users to share a single InvokeAI instance +- Provide user isolation for personal content (boards, images, workflows, settings) +- Maintain centralized model management by administrators +- Support shared boards for collaboration +- Provide secure authentication and authorization +- Minimize impact on existing single-user installations + +### 2.2 Non-Goals +- Real-time collaboration features (multiple users editing same workflow simultaneously) +- Advanced team management features (in initial release) +- Migration of existing multi-user enterprise edition data +- Support for external identity providers (in initial release, can be added later) + +## 3. User Roles and Permissions + +### 3.1 Administrator Role +**Capabilities:** + +- Full access to all InvokeAI features +- Model management (add, delete, configure models) +- User management (create, edit, delete users) +- View and manage all users' queue sessions +- Access system configuration +- Create and manage shared boards +- Grant/revoke administrative privileges to other users + +**Restrictions:** + +- Cannot delete their own account if they are the last administrator +- Cannot revoke their own admin privileges if they are the last administrator + +### 3.2 Regular User Role +**Capabilities:** + +- Create, edit, and delete their own image boards +- Upload and manage their own assets +- Use all image generation tools (linear, canvas, upscale, workflow tabs) +- Create, edit, save, and load workflows +- Access public/shared workflows +- View and manage their own queue sessions +- Adjust personal UI preferences (theme, hotkeys, etc.) +- Access shared boards (read/write based on permissions) +- **View model configurations** (read-only access to model manager) +- **View model details, default settings, and metadata** + +**Restrictions:** + +- Cannot add, delete, or edit models +- **Can view but cannot modify model manager settings** (read-only access) +- Cannot reidentify, convert, or update model paths +- Cannot upload or change model thumbnail images +- Cannot save changes to model default settings +- Cannot perform bulk delete operations on models +- Cannot view or modify other users' boards, images, or workflows +- Cannot cancel or modify other users' queue sessions +- Cannot access system configuration +- Cannot manage users or permissions + +### 3.3 Future Role Considerations +- **Viewer Role**: Read-only access (future enhancement) +- **Team/Group-based Permissions**: Organizational hierarchy (future enhancement) + +## 4. Authentication System + +### 4.1 Authentication Method +- **Primary Method**: Username and password authentication with secure password hashing +- **Password Hashing**: Use bcrypt or Argon2 for password storage +- **Session Management**: JWT tokens or secure session cookies +- **Token Expiration**: Configurable session timeout (default: 7 days for "remember me", 24 hours otherwise) + +### 4.2 Initial Administrator Setup +**First-time Launch Flow:** + +1. Application detects no administrator account exists +2. Displays mandatory setup dialog (cannot be skipped) +3. Prompts for: + - Administrator username (email format recommended) + - Administrator display name + - Strong password (minimum requirements enforced) + - Password confirmation +4. Stores hashed credentials in configuration +5. Creates administrator account in database +6. Proceeds to normal login screen + +**Reset Capability:** + +- Administrators can be reset by manually editing the config file +- Requires access to server filesystem (intentional security measure) +- Database maintains user records; config file contains root admin credentials + +### 4.3 Password Requirements +- Minimum 8 characters +- At least one uppercase letter +- At least one lowercase letter +- At least one number +- At least one special character (optional but recommended) +- Not in common password list + +### 4.4 Login Flow + +1. User navigates to InvokeAI URL +2. If not authenticated, redirect to login page +3. User enters username/email and password +4. Optional "Remember me" checkbox for extended session +5. Backend validates credentials +6. On success: Generate session token, redirect to application +7. On failure: Display error, allow retry with rate limiting (prevent brute force) + +### 4.5 Logout Flow +- User clicks logout button +- Frontend clears session token +- Backend invalidates session (if using server-side sessions) +- Redirect to login page + +### 4.6 Future Authentication Enhancements +- OAuth2/OpenID Connect support +- Two-factor authentication (2FA) +- SSO integration +- API key authentication for programmatic access + +## 5. User Management + +### 5.1 User Creation (Administrator) +**Flow:** + +1. Administrator navigates to user management interface +2. Clicks "Add User" button +3. Enters user information: + - Email address (required, used as username) + - Display name (optional, defaults to email) + - Role (User or Administrator) + - Initial password or "Send invitation email" +4. System validates email uniqueness +5. System creates user account +6. If invitation mode: + - Generate one-time secure token + - Send email with setup link + - Link expires after 7 days +7. If direct password mode: + - Administrator provides initial password + - User must change on first login + +**Invitation Email Flow:** + +1. User receives email with unique link +2. Link contains secure token +3. User clicks link, redirected to setup page +4. User enters desired password +5. Token validated and consumed (single-use) +6. Account activated +7. User redirected to login page + +### 5.2 User Profile Management +**User Self-Service:** + +- Update display name +- Change password (requires current password) +- Update email address (requires verification) +- Manage UI preferences +- View account creation date and last login + +**Administrator Actions:** + +- Edit user information (name, email) +- Reset user password (generates reset link) +- Toggle administrator privileges +- Assign to groups (future feature) +- Suspend/unsuspend account +- Delete account (with data retention options) + +### 5.3 Password Reset Flow +**User-Initiated (Future Enhancement):** + +1. User clicks "Forgot Password" on login page +2. Enters email address +3. System sends password reset link (if email exists) +4. User clicks link, enters new password +5. Password updated, user can login + +**Administrator-Initiated:** + +1. Administrator selects user +2. Clicks "Send Password Reset" +3. System generates reset token and link +4. Email sent to user +5. User follows same flow as user-initiated reset + +## 6. Data Model and Database Schema + +### 6.1 New Tables + +#### 6.1.1 users +```sql +CREATE TABLE users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME +); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_is_admin ON users(is_admin); +CREATE INDEX idx_users_is_active ON users(is_active); +``` + +#### 6.1.2 user_sessions +```sql +CREATE TABLE user_sessions ( + session_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + user_agent TEXT, + ip_address TEXT, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +); +CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at); +CREATE INDEX idx_user_sessions_token_hash ON user_sessions(token_hash); +``` + +#### 6.1.3 user_invitations +```sql +CREATE TABLE user_invitations ( + invitation_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL, + invited_by_user_id TEXT NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (invited_by_user_id) REFERENCES users(user_id) ON DELETE CASCADE +); +CREATE INDEX idx_user_invitations_email ON user_invitations(email); +CREATE INDEX idx_user_invitations_token_hash ON user_invitations(token_hash); +CREATE INDEX idx_user_invitations_expires_at ON user_invitations(expires_at); +``` + +#### 6.1.4 shared_boards +```sql +CREATE TABLE shared_boards ( + board_id TEXT NOT NULL, + user_id TEXT NOT NULL, + permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')), + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + PRIMARY KEY (board_id, user_id), + FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +); +CREATE INDEX idx_shared_boards_user_id ON shared_boards(user_id); +CREATE INDEX idx_shared_boards_board_id ON shared_boards(board_id); +``` + +### 6.2 Modified Tables + +#### 6.2.1 boards +```sql +-- Add columns: +ALTER TABLE boards ADD COLUMN user_id TEXT NOT NULL DEFAULT 'system'; +ALTER TABLE boards ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE boards ADD COLUMN created_by_user_id TEXT; + +-- Add foreign key (requires recreation in SQLite): +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE +FOREIGN KEY (created_by_user_id) REFERENCES users(user_id) ON DELETE SET NULL + +-- Add indices: +CREATE INDEX idx_boards_user_id ON boards(user_id); +CREATE INDEX idx_boards_is_shared ON boards(is_shared); +``` + +#### 6.2.2 images +```sql +-- Add column: +ALTER TABLE images ADD COLUMN user_id TEXT NOT NULL DEFAULT 'system'; + +-- Add foreign key: +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + +-- Add index: +CREATE INDEX idx_images_user_id ON images(user_id); +``` + +#### 6.2.3 workflows +```sql +-- Add columns: +ALTER TABLE workflows ADD COLUMN user_id TEXT NOT NULL DEFAULT 'system'; +ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add foreign key: +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + +-- Add indices: +CREATE INDEX idx_workflows_user_id ON workflows(user_id); +CREATE INDEX idx_workflows_is_public ON workflows(is_public); +``` + +#### 6.2.4 session_queue +```sql +-- Add column: +ALTER TABLE session_queue ADD COLUMN user_id TEXT NOT NULL DEFAULT 'system'; + +-- Add foreign key: +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + +-- Add index: +CREATE INDEX idx_session_queue_user_id ON session_queue(user_id); +``` + +#### 6.2.5 style_presets +```sql +-- Add columns: +ALTER TABLE style_presets ADD COLUMN user_id TEXT NOT NULL DEFAULT 'system'; +ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add foreign key: +FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + +-- Add indices: +CREATE INDEX idx_style_presets_user_id ON style_presets(user_id); +CREATE INDEX idx_style_presets_is_public ON style_presets(is_public); +``` + +### 6.3 Migration Strategy + +1. Create new user tables (users, user_sessions, user_invitations, shared_boards) +2. Create default 'system' user for backward compatibility +3. Update existing data to reference 'system' user +4. Add foreign key constraints +5. Version as database migration (e.g., migration_25.py) + +### 6.4 Migration for Existing Installations +- Single-user installations: Prompt to create admin account on first launch after update +- Existing data migration: Administrator can specify an arbitrary user account to hold legacy data (can be the admin account or a separate user) +- System provides UI during migration to choose destination user for existing data + +## 7. API Endpoints + +### 7.1 Authentication Endpoints + +#### POST /api/v1/auth/setup +- Initialize first administrator account +- Only works if no admin exists +- Body: `{ email, display_name, password }` +- Response: `{ success, user }` + +#### POST /api/v1/auth/login +- Authenticate user +- Body: `{ email, password, remember_me? }` +- Response: `{ token, user, expires_at }` + +#### POST /api/v1/auth/logout +- Invalidate current session +- Headers: `Authorization: Bearer ` +- Response: `{ success }` + +#### GET /api/v1/auth/me +- Get current user information +- Headers: `Authorization: Bearer ` +- Response: `{ user }` + +#### POST /api/v1/auth/change-password +- Change current user's password +- Body: `{ current_password, new_password }` +- Headers: `Authorization: Bearer ` +- Response: `{ success }` + +### 7.2 User Management Endpoints (Admin Only) + +#### GET /api/v1/users +- List all users (paginated) +- Query params: `offset`, `limit`, `search`, `role_filter` +- Response: `{ users[], total, offset, limit }` + +#### POST /api/v1/users +- Create new user +- Body: `{ email, display_name, is_admin, send_invitation?, initial_password? }` +- Response: `{ user, invitation_link? }` + +#### GET /api/v1/users/{user_id} +- Get user details +- Response: `{ user }` + +#### PATCH /api/v1/users/{user_id} +- Update user +- Body: `{ display_name?, is_admin?, is_active? }` +- Response: `{ user }` + +#### DELETE /api/v1/users/{user_id} +- Delete user +- Query params: `delete_data` (true/false) +- Response: `{ success }` + +#### POST /api/v1/users/{user_id}/reset-password +- Send password reset email +- Response: `{ success, reset_link }` + +### 7.3 Shared Boards Endpoints + +#### POST /api/v1/boards/{board_id}/share +- Share board with users +- Body: `{ user_ids[], permission: 'read' | 'write' | 'admin' }` +- Response: `{ success, shared_with[] }` + +#### GET /api/v1/boards/{board_id}/shares +- Get board sharing information +- Response: `{ shares[] }` + +#### DELETE /api/v1/boards/{board_id}/share/{user_id} +- Remove board sharing +- Response: `{ success }` + +### 7.4 Modified Endpoints + +All existing endpoints will be modified to: + +1. Require authentication (except setup/login) +2. Filter data by current user (unless admin viewing all) +3. Enforce permissions (e.g., model management requires admin) +4. Include user context in operations + +Example modifications: +- `GET /api/v1/boards` → Returns only user's boards + shared boards +- `POST /api/v1/session/queue` → Associates queue item with current user +- `GET /api/v1/queue` → Returns all items for admin, only user's items for regular users + +## 8. Frontend Changes + +### 8.1 New Components + +#### LoginPage +- Email/password form +- "Remember me" checkbox +- Login button +- Forgot password link (future) +- Branding and welcome message + +#### AdministratorSetup +- Modal dialog (cannot be dismissed) +- Administrator account creation form +- Password strength indicator +- Terms/welcome message + +#### UserManagementPage (Admin only) +- User list table +- Add user button +- User actions (edit, delete, reset password) +- Search and filter +- Role toggle + +#### UserProfilePage +- Display user information +- Change password form +- UI preferences +- Account details + +#### BoardSharingDialog +- User picker/search +- Permission selector +- Share button +- Current shares list + +### 8.2 Modified Components + +#### App Root +- Add authentication check +- Redirect to login if not authenticated +- Handle session expiration +- Add global error boundary for auth errors + +#### Navigation/Header +- Add user menu with logout +- Display current user name +- Admin indicator badge + +#### ModelManagerTab +- Hide/disable for non-admin users +- Show "Admin only" message + +#### QueuePanel +- Filter by current user (for non-admin) +- Show all with user indicators (for admin) +- Disable actions on other users' items (for non-admin) + +#### BoardsPanel +- Show personal boards section +- Show shared boards section +- Add sharing controls to board actions + +### 8.3 State Management + +New Redux slices/zustand stores: +- `authSlice`: Current user, authentication status, token +- `usersSlice`: User list for admin interface +- `sharingSlice`: Board sharing state + +Updated slices: +- `boardsSlice`: Include shared boards, ownership info +- `queueSlice`: Include user filtering +- `workflowsSlice`: Include public/private status + +## 9. Configuration + +### 9.1 New Config Options + +Add to `InvokeAIAppConfig`: + +```python +# Authentication +auth_enabled: bool = True # Enable/disable multi-user auth +session_expiry_hours: int = 24 # Default session expiration +session_expiry_hours_remember: int = 168 # "Remember me" expiration (7 days) +password_min_length: int = 8 # Minimum password length +require_strong_passwords: bool = True # Enforce password complexity + +# Session tracking +enable_server_side_sessions: bool = False # Optional server-side session tracking + +# Audit logging +audit_log_auth_events: bool = True # Log authentication events +audit_log_admin_actions: bool = True # Log administrative actions + +# Email (optional - for invitations and password reset) +email_enabled: bool = False +smtp_host: str = "" +smtp_port: int = 587 +smtp_username: str = "" +smtp_password: str = "" +smtp_from_address: str = "" +smtp_from_name: str = "InvokeAI" + +# Initial admin (stored as hash) +admin_email: Optional[str] = None +admin_password_hash: Optional[str] = None +``` + +### 9.2 Backward Compatibility + +- If `auth_enabled = False`, system runs in legacy single-user mode +- All data belongs to implicit "system" user +- No authentication required +- Smooth upgrade path for existing installations + +## 10. Security Considerations + +### 10.1 Password Security +- Never store passwords in plain text +- Use bcrypt or Argon2id for password hashing +- Implement proper salt generation +- Enforce password complexity requirements +- Implement rate limiting on login attempts +- Consider password breach checking (Have I Been Pwned API) + +### 10.2 Session Security +- Use cryptographically secure random tokens +- Implement token rotation +- Set appropriate cookie flags (HttpOnly, Secure, SameSite) +- Implement session timeout and renewal +- Invalidate sessions on logout +- Clean up expired sessions periodically + +### 10.3 Authorization +- Always verify user identity from session token (never trust client) +- Check permissions on every API call +- Implement principle of least privilege +- Validate user ownership of resources before operations +- Implement proper error messages (avoid information leakage) + +### 10.4 Data Isolation +- Strict separation of user data in database queries +- Prevent SQL injection via parameterized queries +- Validate all user inputs +- Implement proper access control checks +- Audit trail for sensitive operations + +### 10.5 API Security +- Implement rate limiting on sensitive endpoints +- Use HTTPS in production (enforce via config) +- Implement CSRF protection +- Validate and sanitize all inputs +- Implement proper CORS configuration +- Add security headers (CSP, X-Frame-Options, etc.) + +### 10.6 Deployment Security +- Document secure deployment practices +- Recommend reverse proxy configuration (nginx, Apache) +- Provide example configurations for HTTPS +- Document firewall requirements +- Recommend network isolation strategies + +## 11. Email Integration (Optional) + +**Note**: Email/SMTP configuration is optional. Many administrators will not have ready access to an outgoing SMTP server. When email is not configured, the system provides fallback mechanisms by displaying setup links directly in the admin UI. + +### 11.1 Email Templates + +#### User Invitation +``` +Subject: You've been invited to InvokeAI + +Hello, + +You've been invited to join InvokeAI by [Administrator Name]. + +Click the link below to set up your account: +[Setup Link] + +This link expires in 7 days. + +--- +InvokeAI +``` + +#### Password Reset +``` +Subject: Reset your InvokeAI password + +Hello [User Name], + +A password reset was requested for your account. + +Click the link below to reset your password: +[Reset Link] + +This link expires in 24 hours. + +If you didn't request this, please ignore this email. + +--- +InvokeAI +``` + +### 11.2 Email Service +- Support SMTP configuration +- Use secure connection (TLS) +- Handle email failures gracefully +- Implement email queue for reliability +- Log email activities (without sensitive data) +- Provide fallback for no-email deployments (show links in admin UI) + +## 12. Testing Requirements + +### 12.1 Unit Tests +- Authentication service (password hashing, validation) +- Authorization checks +- Token generation and validation +- User management operations +- Shared board permissions +- Data isolation queries + +### 12.2 Integration Tests +- Complete authentication flows +- User creation and invitation +- Password reset flow +- Multi-user data isolation +- Shared board access +- Session management +- Admin operations + +### 12.3 Security Tests +- SQL injection prevention +- XSS prevention +- CSRF protection +- Session hijacking prevention +- Brute force protection +- Authorization bypass attempts + +### 12.4 Performance Tests +- Authentication overhead +- Query performance with user filters +- Concurrent user sessions +- Database scalability with many users + +## 13. Documentation Requirements + +### 13.1 User Documentation +- Getting started with multi-user InvokeAI +- Login and account management +- Using shared boards +- Understanding permissions +- Troubleshooting authentication issues + +### 13.2 Administrator Documentation +- Setting up multi-user InvokeAI +- User management guide +- Creating and managing shared boards +- Email configuration +- Security best practices +- Backup and restore with user data + +### 13.3 Developer Documentation +- Authentication architecture +- API authentication requirements +- Adding new multi-user features +- Database schema changes +- Testing multi-user features + +### 13.4 Migration Documentation +- Upgrading from single-user to multi-user +- Data migration strategies +- Rollback procedures +- Common issues and solutions + +## 14. Future Enhancements + +### 14.1 Phase 2 Features +- **OAuth2/OpenID Connect integration** (deferred from initial release to keep scope manageable) +- Two-factor authentication +- API keys for programmatic access +- Enhanced team/group management +- Advanced permission system (roles and capabilities) + +### 14.2 Phase 3 Features +- SSO integration (SAML, LDAP) +- User quotas and limits +- Resource usage tracking +- Advanced collaboration features +- Workflow template library with permissions +- Model access controls per user/group + +## 15. Success Metrics + +### 15.1 Functionality Metrics +- Successful user authentication rate +- Zero unauthorized data access incidents +- All tests passing (unit, integration, security) +- API response time within acceptable limits + +### 15.2 Usability Metrics +- User setup completion time < 2 minutes +- Login time < 2 seconds +- Clear error messages for all auth failures +- Positive user feedback on multi-user features + +### 15.3 Security Metrics +- No critical security vulnerabilities identified +- CodeQL scan passes +- Penetration testing completed +- Security best practices followed + +## 16. Risks and Mitigations + +### 16.1 Technical Risks +| Risk | Impact | Probability | Mitigation | +|------|--------|-------------|------------| +| Performance degradation with user filtering | Medium | Low | Index optimization, query caching | +| Database migration failures | High | Low | Thorough testing, rollback procedures | +| Session management complexity | Medium | Medium | Use proven libraries (PyJWT), extensive testing | +| Auth bypass vulnerabilities | High | Low | Security review, penetration testing | + +### 16.2 UX Risks +| Risk | Impact | Probability | Mitigation | +|------|--------|-------------|------------| +| Confusion in migration for existing users | Medium | High | Clear documentation, migration wizard | +| Friction from additional login step | Low | High | Remember me option, long session timeout | +| Complexity of admin interface | Medium | Medium | Intuitive UI design, user testing | + +### 16.3 Operational Risks +| Risk | Impact | Probability | Mitigation | +|------|--------|-------------|------------| +| Email delivery failures | Low | Medium | Show links in UI, document manual methods | +| Lost admin password | High | Low | Document recovery procedure, config reset | +| User data conflicts in migration | Medium | Low | Data validation, backup requirements | + +## 17. Implementation Phases + +### Phase 1: Foundation (Weeks 1-2) +- Database schema design and migration +- Basic authentication service +- Password hashing and validation +- Session management + +### Phase 2: Backend API (Weeks 3-4) +- Authentication endpoints +- User management endpoints +- Authorization middleware +- Update existing endpoints with auth + +### Phase 3: Frontend Auth (Weeks 5-6) +- Login page and flow +- Administrator setup +- Session management +- Auth state management + +### Phase 4: Multi-tenancy (Weeks 7-9) +- User isolation in all services +- Shared boards implementation +- Queue permission filtering +- Workflow public/private + +### Phase 5: Admin Interface (Weeks 10-11) +- User management UI +- Board sharing UI +- Admin-specific features +- User profile page + +### Phase 6: Testing & Polish (Weeks 12-13) +- Comprehensive testing +- Security audit +- Performance optimization +- Documentation +- Bug fixes + +### Phase 7: Beta & Release (Week 14+) +- Beta testing with selected users +- Feedback incorporation +- Final testing +- Release preparation +- Documentation finalization + +## 18. Acceptance Criteria + +- [ ] Administrator can set up initial account on first launch +- [ ] Users can log in with email and password +- [ ] Users can change their password +- [ ] Administrators can create, edit, and delete users +- [ ] User data is properly isolated (boards, images, workflows) +- [ ] Shared boards work correctly with permissions +- [ ] Non-admin users cannot access model management +- [ ] Queue filtering works correctly for users and admins +- [ ] Session management works correctly (expiry, renewal, logout) +- [ ] All security tests pass +- [ ] API documentation is updated +- [ ] User and admin documentation is complete +- [ ] Migration from single-user works smoothly +- [ ] Performance is acceptable with multiple concurrent users +- [ ] Backward compatibility mode works (auth disabled) + +## 19. Design Decisions + +The following design decisions have been approved for implementation: + +1. **OAuth2 Priority**: OAuth2/OpenID Connect integration will be a **future enhancement**. The initial release will focus on username/password authentication to keep scope manageable. + +2. **Email Requirement**: Email/SMTP configuration is **optional**. Many administrators will not have ready access to an outgoing SMTP server. The system will provide fallback mechanisms (showing setup links directly in the admin UI) when email is not configured. + +3. **Data Migration**: During migration from single-user to multi-user mode, the administrator will be given the **option to specify an arbitrary user account** to hold legacy data. The admin account can be used for this purpose if the administrator wishes. + +4. **API Compatibility**: Authentication will be **required on all APIs**, but authentication will not be required if multi-user support is disabled (backward compatibility mode with `auth_enabled: false`). + +5. **Session Storage**: The system will use **JWT tokens with optional server-side session tracking**. This provides scalability while allowing administrators to enable server-side tracking if needed. + +6. **Audit Logging**: The system will **log authentication events and admin actions**. This provides accountability and security monitoring for critical operations. + +## 20. Conclusion + +This specification provides a comprehensive blueprint for implementing multi-user support in InvokeAI. The design prioritizes: + +- **Security**: Proper authentication, authorization, and data isolation +- **Usability**: Intuitive UI, smooth migration, minimal friction +- **Scalability**: Efficient database design, performant queries +- **Maintainability**: Clean architecture, comprehensive testing +- **Flexibility**: Future enhancement paths, optional features + +The phased implementation approach allows for iterative development and testing, while the detailed specifications ensure all stakeholders have clear expectations of the final system. diff --git a/docs/multiuser/user_guide.md b/docs/multiuser/user_guide.md new file mode 100644 index 0000000000..dd0d791aa6 --- /dev/null +++ b/docs/multiuser/user_guide.md @@ -0,0 +1,489 @@ +# InvokeAI Multi-User Guide + +## Overview + +InvokeAI supports both single-user and multi-user modes. In +single-user mode, no login is required and you have access to all +features. In multi-user mode, multiple people can use the same +InvokeAI instance while keeping their work private and organized. + +### Single-User vs Multi-User Mode + +**Single-User Mode:** + +- No login required - direct access to InvokeAI +- All functionality enabled by default +- All boards and images visible in a unified view +- Ideal for personal use or trusted environments +- Enabled when `multiuser: false` in config or option is absent + +**Multi-User Mode:** + +- Secure login required for access +- User isolation for boards, images, and workflows +- Role-based permissions (Administrator vs Regular User) +- Ideal for shared servers or team environments +- Enabled when `multiuser: true` in config + +!!! note "Mode Switching" + + If you switch from multi-user mode to single-user mode, + all boards and images from different users will be combined + into a single unified view. When switching back to multi-user + mode, they will be separated again by user ownership. + +## Getting Started + +### Initial Setup (First Time in Multi-User Mode) + +If you're the first person to access a fresh InvokeAI installation in multi-user mode, you'll see the **Administrator Setup** dialog: + +1. Enter your email address (this will be your username) +2. Create a display name +3. Choose a strong password that meets the requirements: + - At least 8 characters long + - Contains uppercase letters + - Contains lowercase letters + - Contains numbers +4. Confirm your password +5. Click **Create Administrator Account** + +You'll now be taken to a login screen and can enter the credentials +you just created. + +### Accessing InvokeAI + +**In Single-User Mode:** + +1. Navigate to your InvokeAI URL (e.g., `http://localhost:9090`) +2. You'll go directly to the InvokeAI interface +3. No login required - start creating immediately! + +**In Multi-User Mode:** + +1. Navigate to your InvokeAI URL (e.g., `http://localhost:9090`) +2. You'll see the login screen +3. Enter your email address and password provided by your administrator +4. Click **Sign In** + +!!! tip "Remember Me" + In multi-user mode, check the "Remember me" box to stay logged in for 7 days. Otherwise, your session will expire after 24 hours. + +## Understanding User Roles (Multi-User Mode Only) + +In single-user mode, you have access to all features without restrictions. In multi-user mode, InvokeAI has two user roles: + +### Regular User + +As a regular user, you can: + +- ✅ Create and manage your own image boards +- ✅ Generate images using all AI tools (Linear, Canvas, Upscale, Workflows) +- ✅ Create, save, and load your own workflows +- ✅ Access workflows marked as public +- ✅ View your own generation queue +- ✅ Customize your UI preferences (theme, hotkeys, etc.) +- ✅ Access shared boards (based on permissions granted to you) (FUTURE FEATURE) +- ✅ **View available models** (read-only access to Model Manager) + +You cannot: + +- ❌ Add, delete, or modify models +- ❌ View or modify other users' boards, images, or workflows +- ❌ Manage user accounts +- ❌ Access system configuration +- ❌ View or cancel other users' generation tasks + +!!! tip "The generation queue" + + When two or more users are accessing InvokeAI at the same time, + their image generation jobs will be placed on the session queue on + a first-come, first-serve basis. This means that you will have to + wait for other users' image rendering jobs to complete before + yours will start. + + When another user's job is running, you will see the image + generation progress bar and a queue badge that reads `X/Y`, where + "X" is the number of jobs you have queued and "Y" is the total + number of jobs queued, including your own and others. + + You can also pull up the Queue tab in order to see where your job + is in relationship to other queued tasks. + +### Administrator + +Administrators have all regular user capabilities, plus: + +- ✅ Full model management (add, delete, configure models) +- ✅ Create and manage user accounts +- ✅ View and manage all users' generation queues +- ✅ Create and manage shared boards (FUTURE FEATURE) +- ✅ Access system configuration +- ✅ Grant or revoke admin privileges + +## Working with Your Content + +### Image Boards + +Image boards help organize your generated images. Each user has their own private boards. + +**Creating a Board:** + +1. Click the **+** button in the Boards panel +2. Enter a board name +3. Press Enter or click Create + +**Managing Boards:** + +- Click a board to select it +- Generated images will automatically be added to the selected board +- Right-click a board for options (rename, delete, archive) +- Drag images between boards to reorganize + +**Board Visibility:** + +- Your boards are private by default +- Only administrators can create shared boards (FUTURE FEATURE) +- You'll see shared boards you have access to in a separate section + +### Workflows + +Workflows are reusable generation templates that you create in the Workflow Editor. + +**Creating a Workflow:** + +1. Go to the **Workflows** tab +2. Build your workflow using nodes +3. Click **Save** and give it a name +4. Your workflow is saved to your personal library + +**Workflow Privacy:** + +- Your workflows are private by default +- Only you can see and edit your workflows +- Administrators can mark workflows as "public" for all users to access +- Public workflows appear in everyone's workflow library but remain read-only + +### Your Generation Queue + +The queue shows your pending and running generation tasks. + +**Queue Features:** + +- View your current and completed generations +- Cancel pending tasks +- Re-run previous generations +- Monitor progress in real-time + +**Queue Isolation:** + +- You will see your own queue items, as well as the items generated by + either users, but the generation parameters (e.g. prompts) for other + users' are hidden for privacy reasons. +- Administrators can view all queues for troubleshooting +- Your generations won't interfere with other users' tasks + +## Using Shared Boards (FUTURE FEATURE) + +Shared boards are a feature that will be added in a future +release. Administrators will able to designate certain boards as being +accessible to multiple users, allowing for collaboration among users +while maintaining security. + +### Accessing Shared Boards + +Shared boards appear in your Boards panel marked with a sharing icon. You can: + +- View images on shared boards (if you have read access) +- Add images to shared boards (if you have write access) +- Use shared boards like your personal boards + +### Permission Levels + +Shared boards have three permission levels: + +| Permission | View Images | Add Images | Edit/Delete | Manage Sharing | +|------------|-------------|------------|-------------|----------------| +| **Read** | ✅ | ❌ | ❌ | ❌ | +| **Write** | ✅ | ✅ | ✅ | ❌ | +| **Admin** | ✅ | ✅ | ✅ | ✅ | + +!!! note "Shared boards" + Only administrators will be able to create shared boards and + assign initial permissions. + +## Viewing Models (Read-Only) + +Regular users have read-only access to the Model Manager, allowing you to: + +**What You Can View:** + +- ✅ Browse all available models +- ✅ See model details and configurations +- ✅ View default settings for each model +- ✅ Check model metadata and descriptions +- ✅ See which models are installed + +**What You Cannot Do:** + +- ❌ Install new models +- ❌ Delete or modify existing models +- ❌ Change model configurations +- ❌ Upload or change model images +- ❌ Convert models between formats + +**Accessing the Model Manager:** + +1. Click on the **Models** tab in the navigation +2. Browse available models +3. Click on any model to view its details + +!!! tip "Need a New Model?" + If you need a model that isn't installed, ask your administrator to add it. + +## Customizing Your Experience + +### Personal Preferences + +Your UI preferences are saved to your account: + +- **Theme**: Choose between light and dark modes +- **Hotkeys**: Customize keyboard shortcuts +- **Canvas Settings**: Default zoom, grid visibility, etc. +- **Generation Defaults**: Default values for width, height, steps, etc. + +These settings are stored per-user and won't affect other users. + +### Profile Settings (Multi-User Mode) + +In multi-user mode, access your profile by clicking your name in the top-right corner: + +**Display Name:** Update how your name appears throughout the UI + +**Change Password:** + +!!! info "Password Changes" + A web-based interface for users to change their own passwords is coming in a future release. Until then, contact your administrator to reset your password if needed. + +## Security Best Practices + +### Password Security + +- Use a strong, unique password +- Don't share your password with others +- Change your password regularly +- Use a password manager to store complex passwords + +### Session Security + +- Log out when using a shared computer +- Be aware of your session timeout (24 hours or 7 days with "remember me") +- Your session will automatically expire for security +- You'll need to log in again after the session expires + +### Data Privacy + +- Your boards, images, and workflows are private by default +- Other users cannot access your content unless explicitly shared +- Only administrators can see all users' content for management purposes + +## Troubleshooting + +### Cannot Log In + +**Issue:** Login fails with "Incorrect email or password" + +**Solutions:** + +- Verify you're entering the correct email address +- Check that Caps Lock is off +- Try typing the password slowly to avoid mistakes +- Contact your administrator if you've forgotten your password + +**Issue:** Login fails with "Account is disabled" + +**Solution:** Contact your administrator to reactivate your account + +### Session Expired + +**Issue:** You're suddenly logged out and see "Session expired" + +**Explanation:** Sessions expire after 24 hours (or 7 days with "remember me") + +**Solution:** Simply log in again with your credentials + +### Cannot Access Features + +**Issue:** Features like Model Manager show "Admin privileges required" + +**Explanation:** Some features are restricted to administrators + +**Solution:** + +- For model viewing: You can view but not modify models +- For user management: Contact an administrator +- For system configuration: Contact an administrator + +### Missing Boards or Images + +**Issue:** Boards or images you created are not visible + +**Possible Causes:** + +1. **Filter Applied:** Check if a filter is hiding content +2. **Wrong User:** Ensure you're logged in with the correct account +3. **Archived Board:** Check the "Show Archived" option + +**Solution:** + +- Clear any active filters +- Verify you're logged in as the right user +- Check archived items + +### Slow Performance + +**Issue:** Generation or UI feels slower than expected + +**Possible Causes:** + +- Other users generating images simultaneously +- Server resource limits +- Network latency + +**Solutions:** + +- Check the queue to see if others are generating +- Wait for current generations to complete +- Contact administrator if persistent + +### Generation Stuck in Queue + +**Issue:** Your generation is queued but not starting + +**Possible Causes:** + +- Server is processing other users' generations +- Server resources are fully utilized +- Technical issue with the server + +**Solutions:** + +- Wait for your turn in the queue +- Check if your generation is paused +- Contact administrator if stuck for extended period + +## Common Tasks + +### Changing Your Password + +!!! note This is a FUTURE FEATURE. For now, the Administrator must change/reset a user's password using command-line tools. + +1. Click your display name (top-right corner) +2. Select **Change Password** +3. Enter current password +4. Enter new password (8+ characters, mixed case, numbers) +5. Confirm new password +6. Click **Update Password** + +### Creating a New Board + +1. Navigate to the Gallery or Canvas tab +2. Find the Boards panel (usually on the left) +3. Click the **+ New Board** button +4. Type a descriptive name +5. Press Enter + +### Saving a Workflow + +1. Create or edit a workflow in the Workflows tab +2. Click **Save** in the top bar +3. Enter a workflow name +4. Optionally add a description +5. Click **Save Workflow** + +### Finding a Public Workflow + +!!! note Sharing of workflows is a FUTURE FEATURE, not yet implemented + +1. Go to the **Workflows** tab +2. Open the workflow library +3. Public workflows are marked with a 🌐 icon +4. Click to load and use the workflow + +### Logging Out + +1. Click your display name (top-right corner) +2. Select **Logout** +3. You'll be redirected to the login screen + +## Frequently Asked Questions + +### Can other users see my images? + +No, unless you add them to a shared board (FUTURE FEATURE). All your personal boards and images are private. + +### Can I share my workflows with others? + +Not directly. Ask your administrator to mark workflows as public if you want to share them. + +### How long do sessions last? + +- 24 hours by default +- 7 days if you check "Remember me" during login + +### Can I use the API with multi-user mode? + +Yes, but you'll need to authenticate with a JWT token. See the [API Guide](api_guide.md) for details. + +### What happens if I forget my password? + +Contact your administrator. They can reset your password for you. + +### Can I have multiple sessions? + +Yes, you can log in from multiple devices or browsers simultaneously. All sessions will use the same account and see the same content. + +### Why can't I see the Model Manager "Add Models" tab? + +Regular users can see the Models tab but with read-only access. Check that you're logged in and try refreshing the page. + +### How do I know if I'm an administrator? + +Administrators see an "Admin" badge next to their name in the top-right corner and have access to additional features like User Management. + +### Can I request admin privileges? + +Yes, ask your current administrator to grant you admin +privileges. Admin privileges will give you the ability to see all +other user's boards and images, as well as to add models and change +various server-wide settings. + +## Getting Help + +### Support Channels + +- **Administrator:** Contact your system administrator for account issues +- **Documentation:** Check the [FAQ](../faq.md) for common issues +- **Community:** Join the [Discord](https://discord.gg/ZmtBAhwWhy) for help +- **Bug Reports:** File issues on [GitHub](https://github.com/invoke-ai/InvokeAI/issues) + +### Reporting Issues + +When reporting an issue, include: + +- Your role (regular user or administrator) +- What you were trying to do +- What happened instead +- Any error messages you saw +- Your browser and operating system + +## Additional Resources + +- [Administrator Guide](admin_guide.md) - For administrators managing users and the system +- [API Guide](api_guide.md) - For developers using the InvokeAI API +- [Multiuser Specification](specification.md) - Technical details about the feature +- [InvokeAI Documentation](../index.md) - Main documentation hub + +--- + +**Need more help?** Contact your administrator or visit the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy). diff --git a/invokeai/app/api/auth_dependencies.py b/invokeai/app/api/auth_dependencies.py new file mode 100644 index 0000000000..1df1ed6e25 --- /dev/null +++ b/invokeai/app/api/auth_dependencies.py @@ -0,0 +1,166 @@ +"""FastAPI dependencies for authentication.""" + +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.auth.token_service import TokenData, verify_token +from invokeai.backend.util.logging import logging + +logger = logging.getLogger(__name__) + +# HTTP Bearer token security scheme +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], +) -> TokenData: + """Get current authenticated user from Bearer token. + + Note: This function accesses ApiDependencies.invoker.services.users directly, + which is the established pattern in this codebase. The ApiDependencies.invoker + is initialized in the FastAPI lifespan context before any requests are handled. + + Args: + credentials: The HTTP authorization credentials containing the Bearer token + + Returns: + TokenData containing user information from the token + + Raises: + HTTPException: If token is missing, invalid, or expired (401 Unauthorized) + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = credentials.credentials + token_data = verify_token(token) + + if token_data is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 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: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User account is inactive or does not exist", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return token_data + + +async def get_current_user_or_default( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], +) -> 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 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 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: + # 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 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 in multiuser mode - reject + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive") + + return token_data + + +async def require_admin( + current_user: Annotated[TokenData, Depends(get_current_user)], +) -> TokenData: + """Require admin role for the current user. + + Args: + current_user: The current authenticated user's token data + + Returns: + The token data if user is an admin + + Raises: + HTTPException: If user does not have admin privileges (403 Forbidden) + """ + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") + 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)] diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 466a57f804..339a0ceadb 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -5,6 +5,8 @@ from logging import Logger 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 @@ -40,6 +42,7 @@ from invokeai.app.services.shared.sqlite.sqlite_util import init_db from invokeai.app.services.style_preset_images.style_preset_images_disk import StylePresetImageFileStorageDisk from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage from invokeai.app.services.urls.urls_default import LocalUrlService +from invokeai.app.services.users.users_default import UserService from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( @@ -101,6 +104,12 @@ class ApiDependencies: 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 @@ -155,6 +164,7 @@ class ApiDependencies: style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images") workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder) client_state_persistence = ClientStatePersistenceSqlite(db=db) + users = UserService(db=db) services = InvocationServices( board_image_records=board_image_records, @@ -186,6 +196,7 @@ class ApiDependencies: style_preset_image_files=style_preset_image_files, workflow_thumbnails=workflow_thumbnails, client_state_persistence=client_state_persistence, + users=users, ) ApiDependencies.invoker = Invoker(services) diff --git a/invokeai/app/api/no_cache_staticfiles.py b/invokeai/app/api/no_cache_staticfiles.py index 15a53270f1..cbf82d99c7 100644 --- a/invokeai/app/api/no_cache_staticfiles.py +++ b/invokeai/app/api/no_cache_staticfiles.py @@ -1,7 +1,9 @@ from typing import Any +from starlette.exceptions import HTTPException from starlette.responses import Response from starlette.staticfiles import StaticFiles +from starlette.types import Scope class NoCacheStaticFiles(StaticFiles): @@ -12,6 +14,10 @@ class NoCacheStaticFiles(StaticFiles): Static files include the javascript bundles, fonts, locales, and some images. Generated images are not included, as they are served by a router. + + This class also implements proper SPA (Single Page Application) routing by serving index.html + for any routes that don't match static files, enabling client-side routing to work correctly + in production builds. """ def __init__(self, *args: Any, **kwargs: Any): @@ -26,3 +32,19 @@ class NoCacheStaticFiles(StaticFiles): resp.headers.setdefault("Pragma", self.pragma) resp.headers.setdefault("Expires", self.expires) return resp + + async def get_response(self, path: str, scope: Scope) -> Response: + """ + Override get_response to implement SPA routing. + + When a file is not found and html mode is enabled, serve index.html instead of raising a 404. + This allows client-side routing to work correctly in SPAs. + """ + try: + return await super().get_response(path, scope) + except HTTPException as exc: + # If the file is not found (404) and html mode is enabled, serve index.html + # This allows client-side routing to handle the path + if exc.status_code == 404 and self.html: + return await super().get_response("index.html", scope) + raise diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py new file mode 100644 index 0000000000..11f2bacdc5 --- /dev/null +++ b/invokeai/app/api/routers/auth.py @@ -0,0 +1,248 @@ +"""Authentication endpoints.""" + +from datetime import timedelta +from typing import Annotated + +from fastapi import APIRouter, Body, HTTPException, status +from pydantic import BaseModel, Field, field_validator + +from invokeai.app.api.auth_dependencies import CurrentUser +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.auth.token_service import TokenData, create_access_token +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, validate_email_with_special_domains + +auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"]) + +# Token expiration constants (in days) +TOKEN_EXPIRATION_NORMAL = 1 # 1 day for normal login +TOKEN_EXPIRATION_REMEMBER_ME = 7 # 7 days for "remember me" login + + +class LoginRequest(BaseModel): + """Request body for user login.""" + + email: str = Field(description="User email address") + password: str = Field(description="User password") + remember_me: bool = Field(default=False, description="Whether to extend session duration") + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + + +class LoginResponse(BaseModel): + """Response from successful login.""" + + token: str = Field(description="JWT access token") + user: UserDTO = Field(description="User information") + expires_in: int = Field(description="Token expiration time in seconds") + + +class SetupRequest(BaseModel): + """Request body for initial admin setup.""" + + email: str = Field(description="Admin email address") + display_name: str | None = Field(default=None, description="Admin display name") + password: str = Field(description="Admin password") + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + + +class SetupResponse(BaseModel): + """Response from successful admin setup.""" + + success: bool = Field(description="Whether setup was successful") + user: UserDTO = Field(description="Created admin user information") + + +class LogoutResponse(BaseModel): + """Response from logout.""" + + success: bool = Field(description="Whether logout was successful") + + +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 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, multiuser_enabled=True) + + +@auth_router.post("/login", response_model=LoginResponse) +async def login( + request: Annotated[LoginRequest, Body(description="Login credentials")], +) -> LoginResponse: + """Authenticate user and return access token. + + Args: + request: Login credentials (email and password) + + Returns: + LoginResponse containing JWT token and user information + + 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) + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is disabled") + + # Create token with appropriate expiration + expires_delta = timedelta(days=TOKEN_EXPIRATION_REMEMBER_ME if request.remember_me else TOKEN_EXPIRATION_NORMAL) + token_data = TokenData( + user_id=user.user_id, + email=user.email, + is_admin=user.is_admin, + ) + token = create_access_token(token_data, expires_delta) + + return LoginResponse( + token=token, + user=user, + expires_in=int(expires_delta.total_seconds()), + ) + + +@auth_router.post("/logout", response_model=LogoutResponse) +async def logout( + current_user: CurrentUser, +) -> LogoutResponse: + """Logout current user. + + Currently a no-op since we use stateless JWT tokens. For token invalidation in + future implementations, consider: + - Token blacklist: Store invalidated tokens in Redis/database with expiration + - Token versioning: Add version field to user record, increment on logout + - Short-lived tokens: Use refresh token pattern with token rotation + - Session storage: Track active sessions server-side for revocation + + Args: + current_user: The authenticated user (validates token) + + Returns: + LogoutResponse indicating success + """ + # TODO: Implement token invalidation when server-side session management is added + # For now, this is a no-op since we use stateless JWT tokens + return LogoutResponse(success=True) + + +@auth_router.get("/me", response_model=UserDTO) +async def get_current_user_info( + current_user: CurrentUser, +) -> UserDTO: + """Get current authenticated user's information. + + Args: + current_user: The authenticated user's token data + + Returns: + UserDTO containing user information + + Raises: + HTTPException: 404 if user is not found (should not happen normally) + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.get(current_user.user_id) + + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + return user + + +@auth_router.post("/setup", response_model=SetupResponse) +async def setup_admin( + request: Annotated[SetupRequest, Body(description="Admin account details")], +) -> SetupResponse: + """Set up initial administrator account. + + This endpoint can only be called once, when no admin user exists. It creates + the first admin user for the system. + + Args: + request: Admin account details (email, display_name, password) + + Returns: + SetupResponse containing the created admin user + + 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 + if user_service.has_admin(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Administrator account already configured", + ) + + # Create admin user - this will validate password strength + try: + user_data = UserCreateRequest( + email=request.email, + display_name=request.display_name, + password=request.password, + is_admin=True, + ) + user = user_service.create_admin(user_data) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + return SetupResponse(success=True, user=user) diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index cf668d5a1a..e93bb8b2a9 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -4,6 +4,7 @@ from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter from pydantic import BaseModel, Field +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 @@ -32,11 +33,12 @@ class DeleteBoardResult(BaseModel): response_model=BoardDTO, ) async def create_board( + current_user: CurrentUserOrDefault, board_name: str = Query(description="The name of the board to create", max_length=300), ) -> BoardDTO: - """Creates a board""" + """Creates a board for the current user""" try: - result = ApiDependencies.invoker.services.boards.create(board_name=board_name) + result = ApiDependencies.invoker.services.boards.create(board_name=board_name, user_id=current_user.user_id) return result except Exception: raise HTTPException(status_code=500, detail="Failed to create board") @@ -44,16 +46,21 @@ async def create_board( @boards_router.get("/{board_id}", operation_id="get_board", response_model=BoardDTO) async def get_board( + current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of board to get"), ) -> BoardDTO: - """Gets a board""" + """Gets a board (user must have access to it)""" try: result = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) - return result except Exception: raise HTTPException(status_code=404, detail="Board not found") + if not current_user.is_admin and result.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Not authorized to access this board") + + return result + @boards_router.patch( "/{board_id}", @@ -67,10 +74,19 @@ async def get_board( response_model=BoardDTO, ) async def update_board( + 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: - """Updates a board""" + """Updates a board (user must have access to it)""" + try: + board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + except Exception: + raise HTTPException(status_code=404, detail="Board not found") + + if not current_user.is_admin and board.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Not authorized to update this board") + try: result = ApiDependencies.invoker.services.boards.update(board_id=board_id, changes=changes) return result @@ -80,10 +96,19 @@ async def update_board( @boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult) async def delete_board( + 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: - """Deletes a board""" + """Deletes a board (user must have access to it)""" + try: + board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + except Exception: + raise HTTPException(status_code=404, detail="Board not found") + + if not current_user.is_admin and board.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Not authorized to delete this board") + try: if include_images is True: deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( @@ -120,6 +145,7 @@ async def delete_board( response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]], ) async def list_boards( + 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"), @@ -127,11 +153,15 @@ async def list_boards( limit: Optional[int] = Query(default=None, description="The number of boards per page"), include_archived: bool = Query(default=False, description="Whether or not to include archived boards in list"), ) -> Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]]: - """Gets a list of boards""" + """Gets a list of boards for the current user, including shared boards. Admin users see all boards.""" if all: - return ApiDependencies.invoker.services.boards.get_all(order_by, direction, include_archived) + return ApiDependencies.invoker.services.boards.get_all( + current_user.user_id, current_user.is_admin, order_by, direction, include_archived + ) elif offset is not None and limit is not None: - return ApiDependencies.invoker.services.boards.get_many(order_by, direction, offset, limit, include_archived) + return ApiDependencies.invoker.services.boards.get_many( + current_user.user_id, current_user.is_admin, order_by, direction, offset, limit, include_archived + ) else: raise HTTPException( status_code=400, @@ -145,12 +175,22 @@ async def list_boards( response_model=list[str], ) async def list_all_board_image_names( + current_user: CurrentUserOrDefault, board_id: str = Path(description="The id of the board or 'none' for uncategorized images"), categories: list[ImageCategory] | None = Query(default=None, description="The categories of image to include."), is_intermediate: bool | None = Query(default=None, description="Whether to list intermediate images."), ) -> list[str]: """Gets a list of images for a board""" + if board_id != "none": + try: + board = ApiDependencies.invoker.services.boards.get_dto(board_id=board_id) + except Exception: + raise HTTPException(status_code=404, detail="Board not found") + + if not current_user.is_admin and board.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Not authorized to access this board") + image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( board_id, categories, diff --git a/invokeai/app/api/routers/client_state.py b/invokeai/app/api/routers/client_state.py index 188225760c..2e34ea9fe6 100644 --- a/invokeai/app/api/routers/client_state.py +++ b/invokeai/app/api/routers/client_state.py @@ -1,6 +1,7 @@ from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter +from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.backend.util.logging import logging @@ -13,15 +14,16 @@ client_state_router = APIRouter(prefix="/v1/client_state", tags=["client_state"] response_model=str | None, ) async def get_client_state_by_key( - queue_id: str = Path(description="The queue id to perform this operation on"), + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to get"), ) -> str | None: - """Gets the client state""" + """Gets the client state for the current user (or system user if not authenticated)""" try: - return ApiDependencies.invoker.services.client_state_persistence.get_by_key(queue_id, key) + return ApiDependencies.invoker.services.client_state_persistence.get_by_key(current_user.user_id, key) except Exception as e: logging.error(f"Error getting client state: {e}") - raise HTTPException(status_code=500, detail="Error setting client state") + raise HTTPException(status_code=500, detail="Error getting client state") @client_state_router.post( @@ -30,13 +32,14 @@ async def get_client_state_by_key( response_model=str, ) async def set_client_state( - queue_id: str = Path(description="The queue id to perform this operation on"), + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), key: str = Query(..., description="Key to set"), value: str = Body(..., description="Stringified value to set"), ) -> str: - """Sets the client state""" + """Sets the client state for the current user (or system user if not authenticated)""" try: - return ApiDependencies.invoker.services.client_state_persistence.set_by_key(queue_id, key, value) + return ApiDependencies.invoker.services.client_state_persistence.set_by_key(current_user.user_id, key, value) except Exception as e: logging.error(f"Error setting client state: {e}") raise HTTPException(status_code=500, detail="Error setting client state") @@ -48,11 +51,12 @@ async def set_client_state( responses={204: {"description": "Client state deleted"}}, ) async def delete_client_state( - queue_id: str = Path(description="The queue id to perform this operation on"), + current_user: CurrentUserOrDefault, + queue_id: str = Path(description="The queue id (ignored, kept for backwards compatibility)"), ) -> None: - """Deletes the client state""" + """Deletes the client state for the current user (or system user if not authenticated)""" try: - ApiDependencies.invoker.services.client_state_persistence.delete(queue_id) + ApiDependencies.invoker.services.client_state_persistence.delete(current_user.user_id) except Exception as e: logging.error(f"Error deleting client state: {e}") raise HTTPException(status_code=500, detail="Error deleting client state") diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index e9cfa3c28c..6b11762c9e 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -9,6 +9,7 @@ from fastapi.routing import APIRouter from PIL import Image from pydantic import BaseModel, Field, model_validator +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 @@ -61,6 +62,7 @@ class ResizeToDimensions(BaseModel): response_model=ImageDTO, ) async def upload_image( + current_user: CurrentUserOrDefault, file: UploadFile, request: Request, response: Response, @@ -80,7 +82,7 @@ async def upload_image( embed=True, ), ) -> ImageDTO: - """Uploads an image""" + """Uploads an image for the current user""" if not file.content_type or not file.content_type.startswith("image"): raise HTTPException(status_code=415, detail="Not an image") @@ -133,6 +135,7 @@ async def upload_image( workflow=extracted_metadata.invokeai_workflow, graph=extracted_metadata.invokeai_graph, is_intermediate=is_intermediate, + user_id=current_user.user_id, ) response.status_code = 201 @@ -373,6 +376,7 @@ async def get_image_urls( response_model=OffsetPaginatedResults[ImageDTO], ) async def list_image_dtos( + 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."), @@ -386,10 +390,19 @@ async def list_image_dtos( starred_first: bool = Query(default=True, description="Whether to sort by starred images first"), search_term: Optional[str] = Query(default=None, description="The term to search for"), ) -> OffsetPaginatedResults[ImageDTO]: - """Gets a list of image DTOs""" + """Gets a list of image DTOs for the current user""" image_dtos = ApiDependencies.invoker.services.images.get_many( - offset, limit, starred_first, order_dir, image_origin, categories, is_intermediate, board_id, search_term + offset, + limit, + starred_first, + order_dir, + image_origin, + categories, + is_intermediate, + board_id, + search_term, + current_user.user_id, ) return image_dtos @@ -567,6 +580,7 @@ async def get_bulk_download_item( @images_router.get("/names", operation_id="get_image_names") async def get_image_names( + 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."), @@ -589,6 +603,8 @@ async def get_image_names( is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, + user_id=current_user.user_id, + is_admin=current_user.is_admin, ) return result except Exception: diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index d2e6f6096e..a1f6b3a744 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -19,6 +19,7 @@ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field from starlette.exceptions import HTTPException from typing_extensions import Annotated +from invokeai.app.api.auth_dependencies import AdminUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.model_images.model_images_common import ModelImageFileNotFoundException from invokeai.app.services.model_install.model_install_common import ModelInstallJob @@ -229,6 +230,7 @@ async def get_model_record( ) async def reidentify_model( key: Annotated[str, Path(description="Key of the model to reidentify.")], + current_admin: AdminUserOrDefault, ) -> AnyModelConfig: """Attempt to reidentify a model by re-probing its weights file.""" try: @@ -364,6 +366,7 @@ async def get_hugging_face_models( async def update_model_record( key: Annotated[str, Path(description="Unique key of model")], changes: Annotated[ModelRecordChanges, Body(description="Model config", examples=[example_model_input])], + current_admin: AdminUserOrDefault, ) -> AnyModelConfig: """Update a model's config.""" logger = ApiDependencies.invoker.services.logger @@ -426,6 +429,7 @@ async def get_model_image( async def update_model_image( key: Annotated[str, Path(description="Unique key of model")], image: UploadFile, + current_admin: AdminUserOrDefault, ) -> None: if not image.content_type or not image.content_type.startswith("image"): raise HTTPException(status_code=415, detail="Not an image") @@ -459,6 +463,7 @@ async def update_model_image( status_code=204, ) async def delete_model( + current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of model to remove from model registry."), ) -> Response: """ @@ -501,6 +506,7 @@ class BulkDeleteModelsResponse(BaseModel): status_code=200, ) async def bulk_delete_models( + current_admin: AdminUserOrDefault, request: BulkDeleteModelsRequest = Body(description="List of model keys to delete"), ) -> BulkDeleteModelsResponse: """ @@ -542,6 +548,7 @@ async def bulk_delete_models( status_code=204, ) async def delete_model_image( + current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of model image to remove from model_images directory."), ) -> None: logger = ApiDependencies.invoker.services.logger @@ -567,6 +574,7 @@ async def delete_model_image( status_code=201, ) async def install_model( + current_admin: AdminUserOrDefault, source: str = Query(description="Model source to install, can be a local path, repo_id, or remote URL"), inplace: Optional[bool] = Query(description="Whether or not to install a local model in place", default=False), access_token: Optional[str] = Query(description="access token for the remote resource", default=None), @@ -637,6 +645,7 @@ async def install_model( response_class=HTMLResponse, ) async def install_hugging_face_model( + current_admin: AdminUserOrDefault, source: str = Query(description="HuggingFace repo_id to install"), ) -> HTMLResponse: """Install a Hugging Face model using a string identifier.""" @@ -809,7 +818,10 @@ async def get_model_install_job(id: int = Path(description="Model install id")) }, status_code=201, ) -async def cancel_model_install_job(id: int = Path(description="Model install job ID")) -> None: +async def cancel_model_install_job( + current_admin: AdminUserOrDefault, + id: int = Path(description="Model install job ID"), +) -> None: """Cancel the model install job(s) corresponding to the given job ID.""" installer = ApiDependencies.invoker.services.model_manager.install try: @@ -910,7 +922,7 @@ async def restart_model_install_file( 400: {"description": "Bad request"}, }, ) -async def prune_model_install_jobs() -> Response: +async def prune_model_install_jobs(current_admin: AdminUserOrDefault) -> Response: """Prune all completed and errored jobs from the install job list.""" ApiDependencies.invoker.services.model_manager.install.prune_jobs() return Response(status_code=204) @@ -930,6 +942,7 @@ async def prune_model_install_jobs() -> Response: }, ) async def convert_model( + current_admin: AdminUserOrDefault, key: str = Path(description="Unique key of the safetensors main model to convert to diffusers format."), ) -> AnyModelConfig: """ @@ -1111,7 +1124,7 @@ async def get_stats() -> Optional[CacheStats]: operation_id="empty_model_cache", status_code=200, ) -async def empty_model_cache() -> None: +async def empty_model_cache(current_admin: AdminUserOrDefault) -> None: """Drop all models from the model cache to free RAM/VRAM. 'Locked' models that are in active use will not be dropped.""" # Request 1000GB of room in order to force the cache to drop all models. ApiDependencies.invoker.services.logger.info("Emptying model cache.") @@ -1161,6 +1174,7 @@ async def get_hf_login_status() -> HFTokenStatus: @model_manager_router.post("/hf_login", operation_id="do_hf_login", response_model=HFTokenStatus) async def do_hf_login( + current_admin: AdminUserOrDefault, token: str = Body(description="Hugging Face token to use for login", embed=True), ) -> HFTokenStatus: HFTokenHelper.set_token(token) @@ -1173,7 +1187,7 @@ async def do_hf_login( @model_manager_router.delete("/hf_login", operation_id="reset_hf_token", response_model=HFTokenStatus) -async def reset_hf_token() -> HFTokenStatus: +async def reset_hf_token(current_admin: AdminUserOrDefault) -> HFTokenStatus: return HFTokenHelper.reset_token() diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 7b4242e013..403e7727cb 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -4,6 +4,7 @@ from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter from pydantic import BaseModel +from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus from invokeai.app.services.session_queue.session_queue_common import ( @@ -24,6 +25,7 @@ from invokeai.app.services.session_queue.session_queue_common import ( SessionQueueItemNotFoundError, SessionQueueStatus, ) +from invokeai.app.services.shared.graph import Graph, GraphExecutionState from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"]) @@ -36,6 +38,40 @@ class SessionQueueAndProcessorStatus(BaseModel): processor: SessionProcessorStatus +def sanitize_queue_item_for_user( + queue_item: SessionQueueItem, current_user_id: str, is_admin: bool +) -> SessionQueueItem: + """Sanitize queue item for non-admin users viewing other users' items. + + For non-admin users viewing queue items belonging to other users, + the field_values, session graph, and workflow should be hidden/cleared to protect privacy. + + Args: + queue_item: The queue item to sanitize + current_user_id: The ID of the current user viewing the item + is_admin: Whether the current user is an admin + + Returns: + The sanitized queue item (sensitive fields cleared if necessary) + """ + # Admins and item owners can see everything + if is_admin or queue_item.user_id == current_user_id: + return queue_item + + # For non-admins viewing other users' items, clear sensitive fields + # Create a shallow copy to avoid mutating the original + sanitized_item = queue_item.model_copy(deep=False) + sanitized_item.field_values = None + sanitized_item.workflow = None + # Clear the session graph by replacing it with an empty graph execution state + # This prevents information leakage through the generation graph + sanitized_item.session = GraphExecutionState( + id=queue_item.session.id, + graph=Graph(), + ) + return sanitized_item + + @session_queue_router.post( "/{queue_id}/enqueue_batch", operation_id="enqueue_batch", @@ -44,14 +80,15 @@ class SessionQueueAndProcessorStatus(BaseModel): }, ) async def enqueue_batch( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), batch: Batch = Body(description="Batch to process"), prepend: bool = Body(default=False, description="Whether or not to prepend this batch in the queue"), ) -> EnqueueBatchResult: - """Processes a batch and enqueues the output graphs for execution.""" + """Processes a batch and enqueues the output graphs for execution for the current user.""" try: return await ApiDependencies.invoker.services.session_queue.enqueue_batch( - queue_id=queue_id, batch=batch, prepend=prepend + queue_id=queue_id, batch=batch, prepend=prepend, user_id=current_user.user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while enqueuing batch: {e}") @@ -65,15 +102,18 @@ async def enqueue_batch( }, ) async def list_all_queue_items( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), destination: Optional[str] = Query(default=None, description="The destination of queue items to fetch"), ) -> list[SessionQueueItem]: """Gets all queue items""" try: - return ApiDependencies.invoker.services.session_queue.list_all_queue_items( + items = ApiDependencies.invoker.services.session_queue.list_all_queue_items( queue_id=queue_id, destination=destination, ) + # Sanitize items for non-admin users + return [sanitize_queue_item_for_user(item, current_user.user_id, current_user.is_admin) for item in items] except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while listing all queue items: {e}") @@ -102,6 +142,7 @@ async def get_queue_item_ids( responses={200: {"model": list[SessionQueueItem]}}, ) async def get_queue_items_by_item_ids( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body( embed=True, description="Object containing list of queue item ids to fetch queue items for" @@ -118,7 +159,9 @@ async def get_queue_items_by_item_ids( queue_item = session_queue_service.get_queue_item(item_id=item_id) if queue_item.queue_id != queue_id: # Auth protection for items from other queues continue - queue_items.append(queue_item) + # Sanitize item for non-admin users + sanitized_item = sanitize_queue_item_for_user(queue_item, current_user.user_id, current_user.is_admin) + queue_items.append(sanitized_item) except Exception: # Skip missing queue items - they may have been deleted between item id fetch and queue item fetch continue @@ -134,9 +177,10 @@ async def get_queue_items_by_item_ids( responses={200: {"model": SessionProcessorStatus}}, ) async def resume( + current_user: AdminUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: - """Resumes session processor""" + """Resumes session processor. Admin only.""" try: return ApiDependencies.invoker.services.session_processor.resume() except Exception as e: @@ -148,10 +192,11 @@ async def resume( operation_id="pause", responses={200: {"model": SessionProcessorStatus}}, ) -async def Pause( +async def pause( + current_user: AdminUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionProcessorStatus: - """Pauses session processor""" + """Pauses session processor. Admin only.""" try: return ApiDependencies.invoker.services.session_processor.pause() except Exception as e: @@ -164,11 +209,16 @@ async def Pause( responses={200: {"model": CancelAllExceptCurrentResult}}, ) async def cancel_all_except_current( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> CancelAllExceptCurrentResult: - """Immediately cancels all queue items except in-processing items""" + """Immediately cancels all queue items except in-processing items. Non-admin users can only cancel their own items.""" try: - return ApiDependencies.invoker.services.session_queue.cancel_all_except_current(queue_id=queue_id) + # Admin users can cancel all items, non-admin users can only cancel their own + user_id = None if current_user.is_admin else current_user.user_id + return ApiDependencies.invoker.services.session_queue.cancel_all_except_current( + queue_id=queue_id, user_id=user_id + ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling all except current: {e}") @@ -179,11 +229,16 @@ async def cancel_all_except_current( responses={200: {"model": DeleteAllExceptCurrentResult}}, ) async def delete_all_except_current( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> DeleteAllExceptCurrentResult: - """Immediately deletes all queue items except in-processing items""" + """Immediately deletes all queue items except in-processing items. Non-admin users can only delete their own items.""" try: - return ApiDependencies.invoker.services.session_queue.delete_all_except_current(queue_id=queue_id) + # Admin users can delete all items, non-admin users can only delete their own + user_id = None if current_user.is_admin else current_user.user_id + return ApiDependencies.invoker.services.session_queue.delete_all_except_current( + queue_id=queue_id, user_id=user_id + ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while deleting all except current: {e}") @@ -194,13 +249,16 @@ async def delete_all_except_current( responses={200: {"model": CancelByBatchIDsResult}}, ) async def cancel_by_batch_ids( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), batch_ids: list[str] = Body(description="The list of batch_ids to cancel all queue items for", embed=True), ) -> CancelByBatchIDsResult: - """Immediately cancels all queue items from the given batch ids""" + """Immediately cancels all queue items from the given batch ids. Non-admin users can only cancel their own items.""" try: + # Admin users can cancel all items, non-admin users can only cancel their own + user_id = None if current_user.is_admin else current_user.user_id return ApiDependencies.invoker.services.session_queue.cancel_by_batch_ids( - queue_id=queue_id, batch_ids=batch_ids + queue_id=queue_id, batch_ids=batch_ids, user_id=user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by batch id: {e}") @@ -212,13 +270,16 @@ async def cancel_by_batch_ids( responses={200: {"model": CancelByDestinationResult}}, ) async def cancel_by_destination( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), destination: str = Query(description="The destination to cancel all queue items for"), ) -> CancelByDestinationResult: - """Immediately cancels all queue items with the given origin""" + """Immediately cancels all queue items with the given destination. Non-admin users can only cancel their own items.""" try: + # Admin users can cancel all items, non-admin users can only cancel their own + user_id = None if current_user.is_admin else current_user.user_id return ApiDependencies.invoker.services.session_queue.cancel_by_destination( - queue_id=queue_id, destination=destination + queue_id=queue_id, destination=destination, user_id=user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling by destination: {e}") @@ -230,12 +291,28 @@ async def cancel_by_destination( responses={200: {"model": RetryItemsResult}}, ) async def retry_items_by_id( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_ids: list[int] = Body(description="The queue item ids to retry"), ) -> RetryItemsResult: - """Immediately cancels all queue items with the given origin""" + """Retries the given queue items. Users can only retry their own items unless they are an admin.""" try: + # Check authorization: user must own all items or be an admin + if not current_user.is_admin: + for item_id in item_ids: + try: + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + if queue_item.user_id != current_user.user_id: + raise HTTPException( + status_code=403, detail=f"You do not have permission to retry queue item {item_id}" + ) + except SessionQueueItemNotFoundError: + # Skip items that don't exist - they will be handled by retry_items_by_id + continue + return ApiDependencies.invoker.services.session_queue.retry_items_by_id(queue_id=queue_id, item_ids=item_ids) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while retrying queue items: {e}") @@ -248,15 +325,25 @@ async def retry_items_by_id( }, ) async def clear( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> ClearResult: - """Clears the queue entirely, immediately canceling the currently-executing session""" + """Clears the queue entirely. Admin users clear all items; non-admin users only clear their own items. If there's a currently-executing item, users can only cancel it if they own it or are an admin.""" try: queue_item = ApiDependencies.invoker.services.session_queue.get_current(queue_id) if queue_item is not None: + # Check authorization for canceling the current item + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException( + status_code=403, detail="You do not have permission to cancel the currently executing queue item" + ) ApiDependencies.invoker.services.session_queue.cancel_queue_item(queue_item.item_id) - clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id) + # Admin users can clear all items, non-admin users can only clear their own + user_id = None if current_user.is_admin else current_user.user_id + clear_result = ApiDependencies.invoker.services.session_queue.clear(queue_id, user_id=user_id) return clear_result + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while clearing queue: {e}") @@ -269,11 +356,14 @@ async def clear( }, ) async def prune( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> PruneResult: - """Prunes all completed or errored queue items""" + """Prunes all completed or errored queue items. Non-admin users can only prune their own items.""" try: - return ApiDependencies.invoker.services.session_queue.prune(queue_id) + # Admin users can prune all items, non-admin users can only prune their own + user_id = None if current_user.is_admin else current_user.user_id + return ApiDependencies.invoker.services.session_queue.prune(queue_id, user_id=user_id) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while pruning queue: {e}") @@ -320,11 +410,12 @@ async def get_next_queue_item( }, ) async def get_queue_status( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), ) -> SessionQueueAndProcessorStatus: """Gets the status of the session queue""" try: - queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id) + queue = ApiDependencies.invoker.services.session_queue.get_queue_status(queue_id, user_id=current_user.user_id) processor = ApiDependencies.invoker.services.session_processor.get_status() return SessionQueueAndProcessorStatus(queue=queue, processor=processor) except Exception as e: @@ -358,6 +449,7 @@ async def get_batch_status( response_model_exclude_none=True, ) async def get_queue_item( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to get"), ) -> SessionQueueItem: @@ -366,7 +458,8 @@ async def get_queue_item( queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id=item_id) if queue_item.queue_id != queue_id: raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") - return queue_item + # Sanitize item for non-admin users + return sanitize_queue_item_for_user(queue_item, current_user.user_id, current_user.is_admin) except SessionQueueItemNotFoundError: raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") except Exception as e: @@ -378,12 +471,24 @@ async def get_queue_item( operation_id="delete_queue_item", ) async def delete_queue_item( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to delete"), ) -> None: - """Deletes a queue item""" + """Deletes a queue item. Users can only delete their own items unless they are an admin.""" try: + # Get the queue item to check ownership + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + + # Check authorization: user must own the item or be an admin + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="You do not have permission to delete this queue item") + ApiDependencies.invoker.services.session_queue.delete_queue_item(item_id) + except SessionQueueItemNotFoundError: + raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while deleting queue item: {e}") @@ -396,14 +501,24 @@ async def delete_queue_item( }, ) async def cancel_queue_item( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to perform this operation on"), item_id: int = Path(description="The queue item to cancel"), ) -> SessionQueueItem: - """Deletes a queue item""" + """Cancels a queue item. Users can only cancel their own items unless they are an admin.""" try: + # Get the queue item to check ownership + queue_item = ApiDependencies.invoker.services.session_queue.get_queue_item(item_id) + + # Check authorization: user must own the item or be an admin + if queue_item.user_id != current_user.user_id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="You do not have permission to cancel this queue item") + return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id) except SessionQueueItemNotFoundError: raise HTTPException(status_code=404, detail=f"Queue item with id {item_id} not found in queue {queue_id}") + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while canceling queue item: {e}") @@ -432,13 +547,16 @@ async def counts_by_destination( responses={200: {"model": DeleteByDestinationResult}}, ) async def delete_by_destination( + current_user: CurrentUserOrDefault, queue_id: str = Path(description="The queue id to query"), destination: str = Path(description="The destination to query"), ) -> DeleteByDestinationResult: - """Deletes all items with the given destination""" + """Deletes all items with the given destination. Non-admin users can only delete their own items.""" try: + # Admin users can delete all items, non-admin users can only delete their own + user_id = None if current_user.is_admin else current_user.user_id return ApiDependencies.invoker.services.session_queue.delete_by_destination( - queue_id=queue_id, destination=destination + queue_id=queue_id, destination=destination, user_id=user_id ) except Exception as e: raise HTTPException(status_code=500, detail=f"Unexpected error while deleting by destination: {e}") diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py index 9db16aa2d2..fcead54eb1 100644 --- a/invokeai/app/api/sockets.py +++ b/invokeai/app/api/sockets.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from pydantic import BaseModel from socketio import ASGIApp, AsyncServer +from invokeai.app.services.auth.token_service import verify_token from invokeai.app.services.events.events_common import ( BatchEnqueuedEvent, BulkDownloadCompleteEvent, @@ -38,6 +39,9 @@ from invokeai.app.services.events.events_common import ( RecallParametersUpdatedEvent, register_events, ) +from invokeai.backend.util.logging import InvokeAILogger + +logger = InvokeAILogger.get_logger() class QueueSubscriptionEvent(BaseModel): @@ -96,6 +100,13 @@ class SocketIO: self._app = ASGIApp(socketio_server=self._sio, socketio_path="/ws/socket.io") app.mount("/ws", self._app) + # Track user information for each socket connection + self._socket_users: dict[str, dict[str, Any]] = {} + + # Set up authentication middleware + self._sio.on("connect", handler=self._handle_connect) + self._sio.on("disconnect", handler=self._handle_disconnect) + self._sio.on(self._sub_queue, handler=self._handle_sub_queue) self._sio.on(self._unsub_queue, handler=self._handle_unsub_queue) self._sio.on(self._sub_bulk_download, handler=self._handle_sub_bulk_download) @@ -105,8 +116,83 @@ class SocketIO: register_events(MODEL_EVENTS, self._handle_model_event) register_events(BULK_DOWNLOAD_EVENTS, self._handle_bulk_image_download_event) + async def _handle_connect(self, sid: str, environ: dict, auth: dict | None) -> bool: + """Handle socket connection and authenticate the user. + + Returns True to accept the connection, False to reject it. + Stores user_id in the internal socket users dict for later use. + """ + # Extract token from auth data or headers + token = None + if auth and isinstance(auth, dict): + token = auth.get("token") + + if not token and environ: + # Try to get token from headers + headers = environ.get("HTTP_AUTHORIZATION", "") + if headers.startswith("Bearer "): + token = headers[7:] + + # Verify the token + if token: + token_data = verify_token(token) + if token_data: + # Store user_id and is_admin in socket users dict + self._socket_users[sid] = { + "user_id": token_data.user_id, + "is_admin": token_data.is_admin, + } + logger.info( + f"Socket {sid} connected with user_id: {token_data.user_id}, is_admin: {token_data.is_admin}" + ) + return True + + # If no valid token, store system user for backward compatibility + self._socket_users[sid] = { + "user_id": "system", + "is_admin": False, + } + logger.debug(f"Socket {sid} connected as system user (no valid token)") + return True + + async def _handle_disconnect(self, sid: str) -> None: + """Handle socket disconnection and cleanup user info.""" + if sid in self._socket_users: + del self._socket_users[sid] + logger.debug(f"Socket {sid} disconnected and cleaned up") + async def _handle_sub_queue(self, sid: str, data: Any) -> None: - await self._sio.enter_room(sid, QueueSubscriptionEvent(**data).queue_id) + """Handle queue subscription and add socket to both queue and user-specific rooms.""" + queue_id = QueueSubscriptionEvent(**data).queue_id + + # Check if we have user info for this socket + if sid not in self._socket_users: + logger.warning( + f"Socket {sid} subscribing to queue {queue_id} but has no user info - need to authenticate via connect event" + ) + # Store as system user temporarily - real auth should happen in connect + self._socket_users[sid] = { + "user_id": "system", + "is_admin": False, + } + + user_id = self._socket_users[sid]["user_id"] + is_admin = self._socket_users[sid]["is_admin"] + + # Add socket to the queue room + await self._sio.enter_room(sid, queue_id) + + # Also add socket to a user-specific room for event filtering + user_room = f"user:{user_id}" + await self._sio.enter_room(sid, user_room) + + # If admin, also add to admin room to receive all events + if is_admin: + await self._sio.enter_room(sid, "admin") + + logger.debug( + f"Socket {sid} (user_id: {user_id}, is_admin: {is_admin}) subscribed to queue {queue_id} and user room {user_room}" + ) async def _handle_unsub_queue(self, sid: str, data: Any) -> None: await self._sio.leave_room(sid, QueueSubscriptionEvent(**data).queue_id) @@ -118,7 +204,62 @@ class SocketIO: await self._sio.leave_room(sid, BulkDownloadSubscriptionEvent(**data).bulk_download_id) async def _handle_queue_event(self, event: FastAPIEvent[QueueEventBase]): - await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json"), room=event[1].queue_id) + """Handle queue events with user isolation. + + Invocation events (progress, started, complete) are private - only emit to owner and admins. + Queue item status events are public - emit to all users (field values hidden via API). + Other queue events emit to all subscribers. + + IMPORTANT: Check InvocationEventBase BEFORE QueueItemEventBase since InvocationEventBase + inherits from QueueItemEventBase. The order of isinstance checks matters! + """ + try: + event_name, event_data = event + + # Import here to avoid circular dependency + from invokeai.app.services.events.events_common import InvocationEventBase, QueueItemEventBase + + # Check InvocationEventBase FIRST (before QueueItemEventBase) since it's a subclass + # Invocation events (progress, started, complete, error) are private to owner + admins + if isinstance(event_data, InvocationEventBase) and hasattr(event_data, "user_id"): + user_room = f"user:{event_data.user_id}" + + # Emit to the user's room + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room=user_room) + + # Also emit to admin room so admins can see all events, but strip image preview data + # from InvocationProgressEvent to prevent admins from seeing other users' image content + if isinstance(event_data, InvocationProgressEvent): + admin_event_data = event_data.model_copy(update={"image": None}) + await self._sio.emit(event=event_name, data=admin_event_data.model_dump(mode="json"), room="admin") + else: + await self._sio.emit(event=event_name, data=event_data.model_dump(mode="json"), room="admin") + + logger.debug(f"Emitted private invocation event {event_name} to user room {user_room} and admin room") + + # Queue item status events are visible to all users (field values masked via API) + # This catches QueueItemStatusChangedEvent but NOT InvocationEvents (already handled above) + elif isinstance(event_data, QueueItemEventBase) and hasattr(event_data, "user_id"): + # Emit to all subscribers in the queue + await self._sio.emit( + event=event_name, data=event_data.model_dump(mode="json"), room=event_data.queue_id + ) + + logger.info( + f"Emitted public queue item event {event_name} to all subscribers in queue {event_data.queue_id}" + ) + + else: + # For other queue events (like QueueClearedEvent, BatchEnqueuedEvent), emit to all subscribers + await self._sio.emit( + event=event_name, data=event_data.model_dump(mode="json"), room=event_data.queue_id + ) + logger.info( + f"Emitted general queue event {event_name} to all subscribers in queue {event_data.queue_id}" + ) + except Exception as e: + # Log any unhandled exceptions in event handling to prevent silent failures + logger.error(f"Error handling queue event {event[0]}: {e}", exc_info=True) async def _handle_model_event(self, event: FastAPIEvent[ModelEventBase | DownloadEventBase]) -> None: await self._sio.emit(event=event[0], data=event[1].model_dump(mode="json")) diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index e86a397c41..49894dba3c 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -17,6 +17,7 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.no_cache_staticfiles import NoCacheStaticFiles from invokeai.app.api.routers import ( app_info, + auth, board_images, boards, client_state, @@ -122,6 +123,8 @@ app.add_middleware(GZipMiddleware, minimum_size=1000) # Include all routers +# Authentication router should be first so it's registered before protected routes +app.include_router(auth.auth_router, prefix="/api") app.include_router(utilities.utilities_router, prefix="/api") app.include_router(model_manager.model_manager_router, prefix="/api") app.include_router(download_queue.download_queue_router, prefix="/api") diff --git a/invokeai/app/services/app_settings/__init__.py b/invokeai/app/services/app_settings/__init__.py new file mode 100644 index 0000000000..0345874c11 --- /dev/null +++ b/invokeai/app/services/app_settings/__init__.py @@ -0,0 +1,5 @@ +"""App settings service exports.""" + +from invokeai.app.services.app_settings.app_settings_service import AppSettingsService + +__all__ = ["AppSettingsService"] diff --git a/invokeai/app/services/app_settings/app_settings_service.py b/invokeai/app/services/app_settings/app_settings_service.py new file mode 100644 index 0000000000..5580709ef6 --- /dev/null +++ b/invokeai/app/services/app_settings/app_settings_service.py @@ -0,0 +1,74 @@ +"""Service for managing application-level settings stored in the database.""" + +from typing import Optional + +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + + +class AppSettingsService: + """Service for accessing application-level settings from the database. + + This service provides a simple key-value store for application-level configuration + that needs to be persisted across restarts, such as JWT secrets. + """ + + def __init__(self, db: SqliteDatabase) -> None: + """Initialize the app settings service. + + Args: + db: The SQLite database instance + """ + self._db = db + + def get(self, key: str) -> Optional[str]: + """Get a setting value by key. + + Args: + key: The setting key + + Returns: + The setting value if found, None otherwise + """ + try: + with self._db.transaction() as cursor: + cursor.execute("SELECT value FROM app_settings WHERE key = ?;", (key,)) + row = cursor.fetchone() + return row[0] if row else None + except Exception: + return None + + def set(self, key: str, value: str) -> None: + """Set a setting value. + + Args: + key: The setting key + value: The setting value + """ + with self._db.transaction() as cursor: + cursor.execute( + """ + INSERT INTO app_settings (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'); + """, + (key, value), + ) + + def get_jwt_secret(self) -> str: + """Get the JWT secret key from the database. + + Returns: + The JWT secret key + + Raises: + RuntimeError: If the JWT secret is not found in the database + """ + secret = self.get("jwt_secret") + if secret is None: + raise RuntimeError( + "JWT secret not found in database. This should have been created during database migration. " + "Please ensure database migrations have been run successfully." + ) + return secret diff --git a/invokeai/app/services/auth/__init__.py b/invokeai/app/services/auth/__init__.py new file mode 100644 index 0000000000..099a5e7da1 --- /dev/null +++ b/invokeai/app/services/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication service module.""" diff --git a/invokeai/app/services/auth/password_utils.py b/invokeai/app/services/auth/password_utils.py new file mode 100644 index 0000000000..5e64151634 --- /dev/null +++ b/invokeai/app/services/auth/password_utils.py @@ -0,0 +1,86 @@ +"""Password hashing and validation utilities.""" + +from typing import cast + +from passlib.context import CryptContext + +# Configure bcrypt context - set truncate_error=False to allow passwords >72 bytes +# without raising an error. They will be automatically truncated by bcrypt to 72 bytes. +pwd_context = CryptContext( + schemes=["bcrypt"], + deprecated="auto", + bcrypt__truncate_error=False, +) + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt. + + bcrypt has a maximum password length of 72 bytes. Longer passwords + are automatically truncated to comply with this limit. + + Args: + password: The plain text password to hash + + Returns: + The hashed password + """ + # bcrypt has a 72 byte limit - encode and truncate if necessary + password_bytes = password.encode("utf-8") + if len(password_bytes) > 72: + # Truncate to 72 bytes and decode back, dropping incomplete UTF-8 sequences + password = password_bytes[:72].decode("utf-8", errors="ignore") + return cast(str, pwd_context.hash(password)) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash. + + bcrypt has a maximum password length of 72 bytes. Longer passwords + are automatically truncated to match hash_password behavior. + + Args: + plain_password: The plain text password to verify + hashed_password: The hashed password to verify against + + Returns: + True if the password matches the hash, False otherwise + """ + try: + # bcrypt has a 72 byte limit - encode and truncate if necessary to match hash_password + password_bytes = plain_password.encode("utf-8") + if len(password_bytes) > 72: + # Truncate to 72 bytes and decode back, dropping incomplete UTF-8 sequences + plain_password = password_bytes[:72].decode("utf-8", errors="ignore") + return cast(bool, pwd_context.verify(plain_password, hashed_password)) + except Exception: + # Invalid hash format or other error - return False + return False + + +def validate_password_strength(password: str) -> tuple[bool, str]: + """Validate password meets minimum security requirements. + + Password requirements: + - At least 8 characters long + - Contains at least one uppercase letter + - Contains at least one lowercase letter + - Contains at least one digit + + Args: + password: The password to validate + + Returns: + A tuple of (is_valid, error_message). If valid, error_message is empty. + """ + if len(password) < 8: + return False, "Password must be at least 8 characters long" + + has_upper = any(c.isupper() for c in password) + has_lower = any(c.islower() for c in password) + has_digit = any(c.isdigit() for c in password) + + if not (has_upper and has_lower and has_digit): + return False, "Password must contain uppercase, lowercase, and numbers" + + return True, "" diff --git a/invokeai/app/services/auth/token_service.py b/invokeai/app/services/auth/token_service.py new file mode 100644 index 0000000000..9c35261c38 --- /dev/null +++ b/invokeai/app/services/auth/token_service.py @@ -0,0 +1,105 @@ +"""JWT token generation and validation.""" + +from datetime import datetime, timedelta, timezone +from typing import cast + +from jose import JWTError, jwt +from pydantic import BaseModel + +ALGORITHM = "HS256" +DEFAULT_EXPIRATION_HOURS = 24 + +# Module-level variable to store the JWT secret. This is set during application initialization +# by calling set_jwt_secret(). The secret is loaded from the database where it is stored +# securely after being generated during database migration. +_jwt_secret: str | None = None + + +class TokenData(BaseModel): + """Data stored in JWT token.""" + + user_id: str + email: str + is_admin: bool + + +def set_jwt_secret(secret: str) -> None: + """Set the JWT secret key for token signing and verification. + + This should be called once during application initialization with the secret + loaded from the database. + + Args: + secret: The JWT secret key + """ + global _jwt_secret + _jwt_secret = secret + + +def get_jwt_secret() -> str: + """Get the JWT secret key. + + Returns: + The JWT secret key + + Raises: + RuntimeError: If the secret has not been initialized + """ + if _jwt_secret is None: + raise RuntimeError("JWT secret has not been initialized. Call set_jwt_secret() during application startup.") + return _jwt_secret + + +def create_access_token(data: TokenData, expires_delta: timedelta | None = None) -> str: + """Create a JWT access token. + + Args: + data: The token data to encode + expires_delta: Optional expiration time delta. Defaults to 24 hours. + + Returns: + The encoded JWT token + """ + to_encode = data.model_dump() + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(hours=DEFAULT_EXPIRATION_HOURS)) + to_encode.update({"exp": expire}) + return cast(str, jwt.encode(to_encode, get_jwt_secret(), algorithm=ALGORITHM)) + + +def verify_token(token: str) -> TokenData | None: + """Verify and decode a JWT token. + + Args: + token: The JWT token to verify + + Returns: + TokenData if valid, None if invalid or expired + """ + try: + # python-jose 3.5.0 has a bug where exp verification doesn't work properly + # We need to manually check expiration, but MUST verify signature first + # to prevent accepting tokens with valid payloads but invalid signatures + + # First, verify the signature - this will raise JWTError if signature is invalid + # Note: python-jose won't reject expired tokens here due to the bug + payload = jwt.decode( + token, + get_jwt_secret(), + algorithms=[ALGORITHM], + ) + + # Now manually check expiration (because python-jose 3.5.0 doesn't do this properly) + if "exp" in payload: + exp_timestamp = payload["exp"] + current_timestamp = datetime.now(timezone.utc).timestamp() + if current_timestamp >= exp_timestamp: + # Token is expired + return None + + return TokenData(**payload) + except JWTError: + # Token is invalid (bad signature, malformed, etc.) + return None + except Exception: + # Catch any other exceptions (e.g., Pydantic validation errors) + return None diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py index 4cfb565bd3..20981f2c7d 100644 --- a/invokeai/app/services/board_records/board_records_base.py +++ b/invokeai/app/services/board_records/board_records_base.py @@ -17,8 +17,9 @@ class BoardRecordStorageBase(ABC): def save( self, board_name: str, + user_id: str, ) -> BoardRecord: - """Saves a board record.""" + """Saves a board record for a specific user.""" pass @abstractmethod @@ -41,18 +42,25 @@ class BoardRecordStorageBase(ABC): @abstractmethod def get_many( self, + user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, limit: int = 10, include_archived: bool = False, ) -> OffsetPaginatedResults[BoardRecord]: - """Gets many board records.""" + """Gets many board records for a specific user, including shared boards. Admin users see all boards.""" pass @abstractmethod def get_all( - self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + self, + user_id: str, + is_admin: bool, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + include_archived: bool = False, ) -> list[BoardRecord]: - """Gets all board records.""" + """Gets all board records for a specific user, including shared boards. Admin users see all boards.""" pass diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py index 5067d42999..ab6355a393 100644 --- a/invokeai/app/services/board_records/board_records_common.py +++ b/invokeai/app/services/board_records/board_records_common.py @@ -16,6 +16,8 @@ class BoardRecord(BaseModelExcludeNull): """The unique ID of the board.""" board_name: str = Field(description="The name of the board.") """The name of the board.""" + user_id: str = Field(description="The user ID of the board owner.") + """The user ID of the board owner.""" created_at: Union[datetime, str] = Field(description="The created timestamp of the board.") """The created timestamp of the image.""" updated_at: Union[datetime, str] = Field(description="The updated timestamp of the board.") @@ -35,6 +37,8 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: board_id = board_dict.get("board_id", "unknown") board_name = board_dict.get("board_name", "unknown") + # Default to 'system' for backwards compatibility with boards created before multiuser support + user_id = board_dict.get("user_id", "system") cover_image_name = board_dict.get("cover_image_name", "unknown") created_at = board_dict.get("created_at", get_iso_timestamp()) updated_at = board_dict.get("updated_at", get_iso_timestamp()) @@ -44,6 +48,7 @@ def deserialize_board_record(board_dict: dict) -> BoardRecord: return BoardRecord( board_id=board_id, board_name=board_name, + user_id=user_id, cover_image_name=cover_image_name, created_at=created_at, updated_at=updated_at, diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py index 45fe33c540..a54f65686f 100644 --- a/invokeai/app/services/board_records/board_records_sqlite.py +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -38,16 +38,17 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase): def save( self, board_name: str, + user_id: str, ) -> BoardRecord: with self._db.transaction() as cursor: try: board_id = uuid_string() cursor.execute( """--sql - INSERT OR IGNORE INTO boards (board_id, board_name) - VALUES (?, ?); + INSERT OR IGNORE INTO boards (board_id, board_name, user_id) + VALUES (?, ?, ?); """, - (board_id, board_name), + (board_id, board_name, user_id), ) except sqlite3.Error as e: raise BoardRecordSaveException from e @@ -121,6 +122,8 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase): def get_many( self, + user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, @@ -128,74 +131,147 @@ class SqliteBoardRecordStorage(BoardRecordStorageBase): include_archived: bool = False, ) -> OffsetPaginatedResults[BoardRecord]: with self._db.transaction() as cursor: - # Build base query - base_query = """ - SELECT * + # Build base query - admins see all boards, regular users see owned, shared, or public boards + if is_admin: + base_query = """ + SELECT DISTINCT boards.* FROM boards {archived_filter} ORDER BY {order_by} {direction} LIMIT ? OFFSET ?; """ - # Determine archived filter condition - archived_filter = "" if include_archived else "WHERE archived = 0" + # Determine archived filter condition + archived_filter = "WHERE 1=1" if include_archived else "WHERE boards.archived = 0" - final_query = base_query.format( - archived_filter=archived_filter, order_by=order_by.value, direction=direction.value - ) + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) - # Execute query to fetch boards - cursor.execute(final_query, (limit, offset)) + # Execute query to fetch boards + cursor.execute(final_query, (limit, offset)) + else: + base_query = """ + SELECT DISTINCT boards.* + FROM boards + LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1) + {archived_filter} + ORDER BY {order_by} {direction} + LIMIT ? OFFSET ?; + """ + + # Determine archived filter condition + archived_filter = "" if include_archived else "AND boards.archived = 0" + + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) + + # Execute query to fetch boards + cursor.execute(final_query, (user_id, user_id, limit, offset)) result = cast(list[sqlite3.Row], cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] - # Determine count query - if include_archived: - count_query = """ - SELECT COUNT(*) + # Determine count query - admins count all boards, regular users count accessible boards + if is_admin: + if include_archived: + count_query = """ + SELECT COUNT(DISTINCT boards.board_id) FROM boards; """ - else: - count_query = """ - SELECT COUNT(*) + else: + count_query = """ + SELECT COUNT(DISTINCT boards.board_id) FROM boards - WHERE archived = 0; + WHERE boards.archived = 0; + """ + cursor.execute(count_query) + else: + if include_archived: + count_query = """ + SELECT COUNT(DISTINCT boards.board_id) + FROM boards + LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1); + """ + else: + count_query = """ + SELECT COUNT(DISTINCT boards.board_id) + FROM boards + LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1) + AND boards.archived = 0; """ - # Execute count query - cursor.execute(count_query) + # Execute count query + cursor.execute(count_query, (user_id, user_id)) count = cast(int, cursor.fetchone()[0]) return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count) def get_all( - self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + self, + user_id: str, + is_admin: bool, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + include_archived: bool = False, ) -> list[BoardRecord]: with self._db.transaction() as cursor: - if order_by == BoardRecordOrderBy.Name: - base_query = """ - SELECT * + # Build query - admins see all boards, regular users see owned, shared, or public boards + if is_admin: + if order_by == BoardRecordOrderBy.Name: + base_query = """ + SELECT DISTINCT boards.* FROM boards {archived_filter} - ORDER BY LOWER(board_name) {direction} + ORDER BY LOWER(boards.board_name) {direction} """ - else: - base_query = """ - SELECT * + else: + base_query = """ + SELECT DISTINCT boards.* FROM boards {archived_filter} ORDER BY {order_by} {direction} """ - archived_filter = "" if include_archived else "WHERE archived = 0" + archived_filter = "WHERE 1=1" if include_archived else "WHERE boards.archived = 0" - final_query = base_query.format( - archived_filter=archived_filter, order_by=order_by.value, direction=direction.value - ) + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) - cursor.execute(final_query) + cursor.execute(final_query) + else: + if order_by == BoardRecordOrderBy.Name: + base_query = """ + SELECT DISTINCT boards.* + FROM boards + LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1) + {archived_filter} + ORDER BY LOWER(boards.board_name) {direction} + """ + else: + base_query = """ + SELECT DISTINCT boards.* + FROM boards + LEFT JOIN shared_boards ON boards.board_id = shared_boards.board_id + WHERE (boards.user_id = ? OR shared_boards.user_id = ? OR boards.is_public = 1) + {archived_filter} + ORDER BY {order_by} {direction} + """ + + archived_filter = "" if include_archived else "AND boards.archived = 0" + + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) + + cursor.execute(final_query, (user_id, user_id)) result = cast(list[sqlite3.Row], cursor.fetchall()) boards = [deserialize_board_record(dict(r)) for r in result] diff --git a/invokeai/app/services/boards/boards_base.py b/invokeai/app/services/boards/boards_base.py index ed9292a746..914dfa3d0d 100644 --- a/invokeai/app/services/boards/boards_base.py +++ b/invokeai/app/services/boards/boards_base.py @@ -13,8 +13,9 @@ class BoardServiceABC(ABC): def create( self, board_name: str, + user_id: str, ) -> BoardDTO: - """Creates a board.""" + """Creates a board for a specific user.""" pass @abstractmethod @@ -45,18 +46,25 @@ class BoardServiceABC(ABC): @abstractmethod def get_many( self, + user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, limit: int = 10, include_archived: bool = False, ) -> OffsetPaginatedResults[BoardDTO]: - """Gets many boards.""" + """Gets many boards for a specific user, including shared boards. Admin users see all boards.""" pass @abstractmethod def get_all( - self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + self, + user_id: str, + is_admin: bool, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + include_archived: bool = False, ) -> list[BoardDTO]: - """Gets all boards.""" + """Gets all boards for a specific user, including shared boards. Admin users see all boards.""" pass diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py index 68cd360328..99952fec13 100644 --- a/invokeai/app/services/boards/boards_common.py +++ b/invokeai/app/services/boards/boards_common.py @@ -14,10 +14,16 @@ class BoardDTO(BoardRecord): """The number of images in the board.""" asset_count: int = Field(description="The number of assets in the board.") """The number of assets in the board.""" + owner_username: Optional[str] = Field(default=None, description="The username of the board owner (for admin view).") + """The username of the board owner (for admin view).""" def board_record_to_dto( - board_record: BoardRecord, cover_image_name: Optional[str], image_count: int, asset_count: int + board_record: BoardRecord, + cover_image_name: Optional[str], + image_count: int, + asset_count: int, + owner_username: Optional[str] = None, ) -> BoardDTO: """Converts a board record to a board DTO.""" return BoardDTO( @@ -25,4 +31,5 @@ def board_record_to_dto( cover_image_name=cover_image_name, image_count=image_count, asset_count=asset_count, + owner_username=owner_username, ) diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py index 3ba0e7445f..71465815ef 100644 --- a/invokeai/app/services/boards/boards_default.py +++ b/invokeai/app/services/boards/boards_default.py @@ -15,8 +15,9 @@ class BoardService(BoardServiceABC): def create( self, board_name: str, + user_id: str, ) -> BoardDTO: - board_record = self.__invoker.services.board_records.save(board_name) + board_record = self.__invoker.services.board_records.save(board_name, user_id) return board_record_to_dto(board_record, None, 0, 0) def get_dto(self, board_id: str) -> BoardDTO: @@ -51,6 +52,8 @@ class BoardService(BoardServiceABC): def get_many( self, + user_id: str, + is_admin: bool, order_by: BoardRecordOrderBy, direction: SQLiteDirection, offset: int = 0, @@ -58,7 +61,7 @@ class BoardService(BoardServiceABC): include_archived: bool = False, ) -> OffsetPaginatedResults[BoardDTO]: board_records = self.__invoker.services.board_records.get_many( - order_by, direction, offset, limit, include_archived + user_id, is_admin, order_by, direction, offset, limit, include_archived ) board_dtos = [] for r in board_records.items: @@ -70,14 +73,29 @@ class BoardService(BoardServiceABC): image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id) asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count)) + + # For admin users, include owner username + owner_username = None + if is_admin: + owner = self.__invoker.services.users.get(r.user_id) + if owner: + owner_username = owner.display_name or owner.email + + board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, owner_username)) return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) def get_all( - self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + self, + user_id: str, + is_admin: bool, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + include_archived: bool = False, ) -> list[BoardDTO]: - board_records = self.__invoker.services.board_records.get_all(order_by, direction, include_archived) + board_records = self.__invoker.services.board_records.get_all( + user_id, is_admin, order_by, direction, include_archived + ) board_dtos = [] for r in board_records: cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(r.board_id) @@ -88,6 +106,14 @@ class BoardService(BoardServiceABC): image_count = self.__invoker.services.board_image_records.get_image_count_for_board(r.board_id) asset_count = self.__invoker.services.board_image_records.get_asset_count_for_board(r.board_id) - board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count)) + + # For admin users, include owner username + owner_username = None + if is_admin: + owner = self.__invoker.services.users.get(r.user_id) + if owner: + owner_username = owner.display_name or owner.email + + board_dtos.append(board_record_to_dto(r, cover_image_name, image_count, asset_count, owner_username)) return board_dtos diff --git a/invokeai/app/services/client_state_persistence/client_state_persistence_base.py b/invokeai/app/services/client_state_persistence/client_state_persistence_base.py index 193561ef89..99ad71bc8b 100644 --- a/invokeai/app/services/client_state_persistence/client_state_persistence_base.py +++ b/invokeai/app/services/client_state_persistence/client_state_persistence_base.py @@ -4,15 +4,16 @@ from abc import ABC, abstractmethod class ClientStatePersistenceABC(ABC): """ Base class for client persistence implementations. - This class defines the interface for persisting client data. + This class defines the interface for persisting client data per user. """ @abstractmethod - def set_by_key(self, queue_id: str, key: str, value: str) -> str: + def set_by_key(self, user_id: str, key: str, value: str) -> str: """ Set a key-value pair for the client. Args: + user_id (str): The user ID to set state for. key (str): The key to set. value (str): The value to set for the key. @@ -22,11 +23,12 @@ class ClientStatePersistenceABC(ABC): pass @abstractmethod - def get_by_key(self, queue_id: str, key: str) -> str | None: + def get_by_key(self, user_id: str, key: str) -> str | None: """ Get the value for a specific key of the client. Args: + user_id (str): The user ID to get state for. key (str): The key to retrieve the value for. Returns: @@ -35,8 +37,11 @@ class ClientStatePersistenceABC(ABC): pass @abstractmethod - def delete(self, queue_id: str) -> None: + def delete(self, user_id: str) -> None: """ - Delete all client state. + Delete all client state for a user. + + Args: + user_id (str): The user ID to delete state for. """ pass diff --git a/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py b/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py index 36f22d9676..643db30685 100644 --- a/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py +++ b/invokeai/app/services/client_state_persistence/client_state_persistence_sqlite.py @@ -1,5 +1,3 @@ -import json - from invokeai.app.services.client_state_persistence.client_state_persistence_base import ClientStatePersistenceABC from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase @@ -7,59 +5,51 @@ from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase class ClientStatePersistenceSqlite(ClientStatePersistenceABC): """ - Base class for client persistence implementations. - This class defines the interface for persisting client data. + SQLite implementation for client state persistence. + This class stores client state data per user to prevent data leakage between users. """ def __init__(self, db: SqliteDatabase) -> None: super().__init__() self._db = db - self._default_row_id = 1 def start(self, invoker: Invoker) -> None: self._invoker = invoker - def _get(self) -> dict[str, str] | None: + def set_by_key(self, user_id: str, key: str, value: str) -> str: with self._db.transaction() as cursor: cursor.execute( - f""" - SELECT data FROM client_state - WHERE id = {self._default_row_id} """ - ) - row = cursor.fetchone() - if row is None: - return None - return json.loads(row[0]) - - def set_by_key(self, queue_id: str, key: str, value: str) -> str: - state = self._get() or {} - state.update({key: value}) - - with self._db.transaction() as cursor: - cursor.execute( - f""" - INSERT INTO client_state (id, data) - VALUES ({self._default_row_id}, ?) - ON CONFLICT(id) DO UPDATE - SET data = excluded.data; + INSERT INTO client_state (user_id, key, value) + VALUES (?, ?, ?) + ON CONFLICT(user_id, key) DO UPDATE + SET value = excluded.value; """, - (json.dumps(state),), + (user_id, key, value), ) return value - def get_by_key(self, queue_id: str, key: str) -> str | None: - state = self._get() - if state is None: - return None - return state.get(key, None) - - def delete(self, queue_id: str) -> None: + def get_by_key(self, user_id: str, key: str) -> str | None: with self._db.transaction() as cursor: cursor.execute( - f""" - DELETE FROM client_state - WHERE id = {self._default_row_id} """ + SELECT value FROM client_state + WHERE user_id = ? AND key = ? + """, + (user_id, key), + ) + row = cursor.fetchone() + if row is None: + return None + return row[0] + + def delete(self, user_id: str) -> None: + with self._db.transaction() as cursor: + cursor.execute( + """ + DELETE FROM client_state + WHERE user_id = ? + """, + (user_id,), ) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 93b2d01e1e..2cc2aaf273 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -110,6 +110,7 @@ class InvokeAIAppConfig(BaseSettings): scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes. unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production. allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation. + multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. """ _root: Optional[Path] = PrivateAttr(default=None) @@ -203,6 +204,9 @@ class InvokeAIAppConfig(BaseSettings): unsafe_disable_picklescan: bool = Field(default=False, description="UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.") allow_unknown_models: bool = Field(default=True, description="Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.") + # MULTIUSER + multiuser: bool = Field(default=False, description="Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.") + # fmt: on model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True) diff --git a/invokeai/app/services/download/download_default.py b/invokeai/app/services/download/download_default.py index f65f00e2ae..9b5fda5620 100644 --- a/invokeai/app/services/download/download_default.py +++ b/invokeai/app/services/download/download_default.py @@ -60,7 +60,7 @@ class DownloadQueueService(DownloadQueueServiceBase): """ self._app_config = app_config or get_config() self._jobs: Dict[int, DownloadJob] = {} - self._download_part2parent: Dict[AnyHttpUrl, MultiFileDownloadJob] = {} + self._download_part2parent: Dict[int, MultiFileDownloadJob] = {} self._mfd_pending: Dict[int, list[DownloadJob]] = {} self._mfd_active: Dict[int, DownloadJob] = {} self._next_job_id = 0 @@ -118,7 +118,8 @@ class DownloadQueueService(DownloadQueueServiceBase): raise ServiceInactiveException( "The download service is not currently accepting requests. Please call start() to initialize the service." ) - job.id = self._next_id() + if job.id == -1: + job.id = self._next_id() job.set_callbacks( on_start=on_start, on_progress=on_progress, @@ -197,12 +198,13 @@ class DownloadQueueService(DownloadQueueServiceBase): dest=path, access_token=access_token or self._lookup_access_token(url), ) + job.id = self._next_id() # pre-assign ID so _download_part2parent can be keyed by ID if part.size and part.size > 0: job.total_bytes = part.size job.expected_total_bytes = part.size job.canonical_url = str(url) mfdj.download_parts.add(job) - self._download_part2parent[job.source] = mfdj + self._download_part2parent[job.id] = mfdj if submit_job: self.submit_multifile_download(mfdj) return mfdj @@ -327,7 +329,7 @@ class DownloadQueueService(DownloadQueueServiceBase): finally: job.job_ended = get_iso_timestamp() self._job_terminated_event.set() # signal a change to terminal state - self._download_part2parent.pop(job.source, None) # if this is a subpart of a multipart job, remove it + self._download_part2parent.pop(job.id, None) # if this is a subpart of a multipart job, remove it self._queue.task_done() self._logger.debug(f"Download queue worker thread {threading.current_thread().name} exiting.") @@ -622,7 +624,7 @@ class DownloadQueueService(DownloadQueueServiceBase): self._event_bus.emit_download_cancelled(job) # if multifile download, then signal the parent - if parent_job := self._download_part2parent.get(job.source, None): + if parent_job := self._download_part2parent.get(job.id, None): if not parent_job.in_terminal_state: parent_job.status = DownloadJobStatus.CANCELLED self._execute_cb(parent_job, "on_cancelled") @@ -639,7 +641,7 @@ class DownloadQueueService(DownloadQueueServiceBase): if self._event_bus: self._event_bus.emit_download_paused(job) - if parent_job := self._download_part2parent.get(job.source, None): + if parent_job := self._download_part2parent.get(job.id, None): if not parent_job.in_terminal_state: parent_job.status = DownloadJobStatus.PAUSED self._execute_cb(parent_job, "on_cancelled") @@ -669,7 +671,7 @@ class DownloadQueueService(DownloadQueueServiceBase): def _mfd_started(self, download_job: DownloadJob) -> None: self._logger.info(f"File download started: {download_job.source}") with self._lock: - mf_job = self._download_part2parent[download_job.source] + mf_job = self._download_part2parent[download_job.id] if mf_job.waiting: mf_job.total_bytes = sum(x.total_bytes for x in mf_job.download_parts) mf_job.status = DownloadJobStatus.RUNNING @@ -682,7 +684,7 @@ class DownloadQueueService(DownloadQueueServiceBase): def _mfd_progress(self, download_job: DownloadJob) -> None: with self._lock: - mf_job = self._download_part2parent[download_job.source] + mf_job = self._download_part2parent[download_job.id] if mf_job.cancelled: for part in mf_job.download_parts: self.cancel_job(part) @@ -696,7 +698,7 @@ class DownloadQueueService(DownloadQueueServiceBase): submit_next = False mf_job: Optional[MultiFileDownloadJob] = None with self._lock: - mf_job = self._download_part2parent[download_job.source] + mf_job = self._download_part2parent[download_job.id] self._mfd_active.pop(mf_job.id, None) mf_job.total_bytes = sum(x.total_bytes for x in mf_job.download_parts) mf_job.bytes = sum(x.bytes for x in mf_job.download_parts) @@ -715,7 +717,7 @@ class DownloadQueueService(DownloadQueueServiceBase): def _mfd_cancelled(self, download_job: DownloadJob) -> None: with self._lock: - mf_job = self._download_part2parent[download_job.source] + mf_job = self._download_part2parent[download_job.id] assert mf_job is not None self._mfd_active.pop(mf_job.id, None) @@ -735,7 +737,7 @@ class DownloadQueueService(DownloadQueueServiceBase): def _mfd_error(self, download_job: DownloadJob, excp: Optional[Exception] = None) -> None: with self._lock: - mf_job = self._download_part2parent[download_job.source] + mf_job = self._download_part2parent[download_job.id] assert mf_job is not None self._mfd_active.pop(mf_job.id, None) if not mf_job.in_terminal_state: @@ -748,7 +750,6 @@ class DownloadQueueService(DownloadQueueServiceBase): ) for s in [x for x in mf_job.download_parts if x.running]: self.cancel_job(s) - self._download_part2parent.pop(download_job.source) self._mfd_pending.pop(mf_job.id, None) self._job_terminated_event.set() diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py index cab5861b11..bfb44eb48e 100644 --- a/invokeai/app/services/events/events_common.py +++ b/invokeai/app/services/events/events_common.py @@ -91,6 +91,7 @@ class QueueItemEventBase(QueueEventBase): batch_id: str = Field(description="The ID of the queue batch") origin: str | None = Field(default=None, description="The origin of the queue item") destination: str | None = Field(default=None, description="The destination of the queue item") + user_id: str = Field(default="system", description="The ID of the user who created the queue item") class InvocationEventBase(QueueItemEventBase): @@ -117,6 +118,7 @@ class InvocationStartedEvent(InvocationEventBase): batch_id=queue_item.batch_id, origin=queue_item.origin, destination=queue_item.destination, + user_id=queue_item.user_id, session_id=queue_item.session_id, invocation=invocation, invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], @@ -152,6 +154,7 @@ class InvocationProgressEvent(InvocationEventBase): batch_id=queue_item.batch_id, origin=queue_item.origin, destination=queue_item.destination, + user_id=queue_item.user_id, session_id=queue_item.session_id, invocation=invocation, invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], @@ -179,6 +182,7 @@ class InvocationCompleteEvent(InvocationEventBase): batch_id=queue_item.batch_id, origin=queue_item.origin, destination=queue_item.destination, + user_id=queue_item.user_id, session_id=queue_item.session_id, invocation=invocation, invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], @@ -211,6 +215,7 @@ class InvocationErrorEvent(InvocationEventBase): batch_id=queue_item.batch_id, origin=queue_item.origin, destination=queue_item.destination, + user_id=queue_item.user_id, session_id=queue_item.session_id, invocation=invocation, invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], @@ -248,6 +253,7 @@ class QueueItemStatusChangedEvent(QueueItemEventBase): batch_id=queue_item.batch_id, origin=queue_item.origin, destination=queue_item.destination, + user_id=queue_item.user_id, session_id=queue_item.session_id, status=queue_item.status, error_type=queue_item.error_type, diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index ff271e2394..16405c5270 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -50,8 +50,10 @@ class ImageRecordStorageBase(ABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageRecord]: - """Gets a page of image records.""" + """Gets a page of image records. When board_id is 'none', filters by user_id for per-user uncategorized images unless is_admin is True.""" pass # TODO: The database has a nullable `deleted_at` column, currently unused. @@ -90,6 +92,7 @@ class ImageRecordStorageBase(ABC): session_id: Optional[str] = None, node_id: Optional[str] = None, metadata: Optional[str] = None, + user_id: Optional[str] = None, ) -> datetime: """Saves an image record.""" pass @@ -109,6 +112,8 @@ class ImageRecordStorageBase(ABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: """Gets ordered list of image names with metadata for optimistic updates.""" pass diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index cb968e76bb..c6c237fc1e 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -134,6 +134,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageRecord]: with self._db.transaction() as cursor: # Manually build two queries - one for the count, one for the records @@ -186,6 +188,13 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): query_conditions += """--sql AND board_images.board_id IS NULL """ + # For uncategorized images, filter by user_id to ensure per-user isolation + # Admin users can see all uncategorized images from all users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) elif board_id is not None: query_conditions += """--sql AND board_images.board_id = ? @@ -305,6 +314,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): session_id: Optional[str] = None, node_id: Optional[str] = None, metadata: Optional[str] = None, + user_id: Optional[str] = None, ) -> datetime: with self._db.transaction() as cursor: try: @@ -321,9 +331,10 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): metadata, is_intermediate, starred, - has_workflow + has_workflow, + user_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """, ( image_name, @@ -337,6 +348,7 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): is_intermediate, starred, has_workflow, + user_id or "system", ), ) @@ -386,6 +398,8 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: with self._db.transaction() as cursor: # Build query conditions (reused for both starred count and image names queries) @@ -417,6 +431,13 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): query_conditions += """--sql AND board_images.board_id IS NULL """ + # For uncategorized images, filter by user_id to ensure per-user isolation + # Admin users can see all uncategorized images from all users + if user_id is not None and not is_admin: + query_conditions += """--sql + AND images.user_id = ? + """ + query_params.append(user_id) elif board_id is not None: query_conditions += """--sql AND board_images.board_id = ? diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py index e1fe02c1ec..d11d75b3c1 100644 --- a/invokeai/app/services/images/images_base.py +++ b/invokeai/app/services/images/images_base.py @@ -55,6 +55,7 @@ class ImageServiceABC(ABC): metadata: Optional[str] = None, workflow: Optional[str] = None, graph: Optional[str] = None, + user_id: Optional[str] = None, ) -> ImageDTO: """Creates an image, storing the file and its metadata.""" pass @@ -125,6 +126,8 @@ class ImageServiceABC(ABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageDTO]: """Gets a paginated list of image DTOs with starred images first when starred_first=True.""" pass @@ -159,6 +162,8 @@ class ImageServiceABC(ABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: """Gets ordered list of image names with metadata for optimistic updates.""" pass diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index 64ef0751b2..e82bd7f4de 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -45,6 +45,7 @@ class ImageService(ImageServiceABC): metadata: Optional[str] = None, workflow: Optional[str] = None, graph: Optional[str] = None, + user_id: Optional[str] = None, ) -> ImageDTO: if image_origin not in ResourceOrigin: raise InvalidOriginException @@ -72,6 +73,7 @@ class ImageService(ImageServiceABC): node_id=node_id, metadata=metadata, session_id=session_id, + user_id=user_id, ) if board_id is not None: try: @@ -215,6 +217,8 @@ class ImageService(ImageServiceABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> OffsetPaginatedResults[ImageDTO]: try: results = self.__invoker.services.image_records.get_many( @@ -227,6 +231,8 @@ class ImageService(ImageServiceABC): is_intermediate, board_id, search_term, + user_id, + is_admin, ) image_dtos = [ @@ -320,6 +326,8 @@ class ImageService(ImageServiceABC): is_intermediate: Optional[bool] = None, board_id: Optional[str] = None, search_term: Optional[str] = None, + user_id: Optional[str] = None, + is_admin: bool = False, ) -> ImageNamesResult: try: return self.__invoker.services.image_records.get_image_names( @@ -330,6 +338,8 @@ class ImageService(ImageServiceABC): is_intermediate=is_intermediate, board_id=board_id, search_term=search_term, + user_id=user_id, + is_admin=is_admin, ) except Exception as e: self.__invoker.services.logger.error("Problem getting image names") diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 52fb064596..7a33f49940 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from invokeai.app.services.session_processor.session_processor_base import SessionProcessorBase from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase from invokeai.app.services.urls.urls_base import UrlServiceBase + from invokeai.app.services.users.users_base import UserServiceBase from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_base import WorkflowThumbnailServiceBase from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData @@ -75,6 +76,7 @@ class InvocationServices: style_preset_image_files: "StylePresetImageFileStorageBase", workflow_thumbnails: "WorkflowThumbnailServiceBase", client_state_persistence: "ClientStatePersistenceABC", + users: "UserServiceBase", ): self.board_images = board_images self.board_image_records = board_image_records @@ -105,3 +107,4 @@ class InvocationServices: self.style_preset_image_files = style_preset_image_files self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence + self.users = users diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index c1fb0c651f..c47267eab5 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -186,6 +186,11 @@ class ModelInstallService(ModelInstallServiceBase): def _restore_incomplete_installs(self) -> None: path = self._app_config.models_path seen_sources: set[str] = set() + # Collect sources already tracked by active jobs (including those being downloaded right now). + # We must not re-queue these or delete their tmpdirs. + with self._lock: + active_sources = {str(j.source) for j in self._install_jobs if not j.in_terminal_state} + active_sources.update(str(j.source) for j in self._download_cache.values() if not j.in_terminal_state) for tmpdir in path.glob(f"{TMPDIR_PREFIX}*"): marker = self._read_install_marker(tmpdir) if not marker: @@ -196,6 +201,10 @@ class ModelInstallService(ModelInstallServiceBase): try: source_str = marker["source"] + if source_str in active_sources: + # This tmpdir belongs to an install already in progress; leave it alone. + self._logger.debug(f"Skipping restore for {source_str} - already being tracked") + continue if source_str in seen_sources: self._logger.info(f"Removing duplicate temporary directory {tmpdir}") self._safe_rmtree(tmpdir, self._logger) diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py index 2b8f05b8e7..3c037dc77a 100644 --- a/invokeai/app/services/session_queue/session_queue_base.py +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -36,8 +36,10 @@ class SessionQueueBase(ABC): pass @abstractmethod - def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> Coroutine[Any, Any, EnqueueBatchResult]: - """Enqueues all permutations of a batch for execution.""" + def enqueue_batch( + self, queue_id: str, batch: Batch, prepend: bool, user_id: str = "system" + ) -> Coroutine[Any, Any, EnqueueBatchResult]: + """Enqueues all permutations of a batch for execution for a specific user.""" pass @abstractmethod @@ -51,13 +53,13 @@ class SessionQueueBase(ABC): pass @abstractmethod - def clear(self, queue_id: str) -> ClearResult: - """Deletes all session queue items""" + def clear(self, queue_id: str, user_id: Optional[str] = None) -> ClearResult: + """Deletes all session queue items. If user_id is provided, only clears items owned by that user.""" pass @abstractmethod - def prune(self, queue_id: str) -> PruneResult: - """Deletes all completed and errored session queue items""" + def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult: + """Deletes all completed and errored session queue items. If user_id is provided, only prunes items owned by that user.""" pass @abstractmethod @@ -71,8 +73,8 @@ class SessionQueueBase(ABC): pass @abstractmethod - def get_queue_status(self, queue_id: str) -> SessionQueueStatus: - """Gets the status of the queue""" + def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> SessionQueueStatus: + """Gets the status of the queue. If user_id is provided, also includes user-specific counts.""" pass @abstractmethod @@ -108,18 +110,24 @@ class SessionQueueBase(ABC): pass @abstractmethod - def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult: - """Cancels all queue items with matching batch IDs""" + def cancel_by_batch_ids( + self, queue_id: str, batch_ids: list[str], user_id: Optional[str] = None + ) -> CancelByBatchIDsResult: + """Cancels all queue items with matching batch IDs. If user_id is provided, only cancels items owned by that user.""" pass @abstractmethod - def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult: - """Cancels all queue items with the given batch destination""" + def cancel_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> CancelByDestinationResult: + """Cancels all queue items with the given batch destination. If user_id is provided, only cancels items owned by that user.""" pass @abstractmethod - def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult: - """Deletes all queue items with the given batch destination""" + def delete_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> DeleteByDestinationResult: + """Deletes all queue items with the given batch destination. If user_id is provided, only deletes items owned by that user.""" pass @abstractmethod @@ -128,13 +136,13 @@ class SessionQueueBase(ABC): pass @abstractmethod - def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult: - """Cancels all queue items except in-progress items""" + def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult: + """Cancels all queue items except in-progress items. If user_id is provided, only cancels items owned by that user.""" pass @abstractmethod - def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult: - """Deletes all queue items except in-progress items""" + def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult: + """Deletes all queue items except in-progress items. If user_id is provided, only deletes items owned by that user.""" pass @abstractmethod diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py index 57b512a855..5854442211 100644 --- a/invokeai/app/services/session_queue/session_queue_common.py +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -170,6 +170,7 @@ class Batch(BaseModel): # region Queue Items DEFAULT_QUEUE_ID = "default" +SYSTEM_USER_ID = "system" # Default user_id for system-generated queue items QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "completed", "failed", "canceled"] @@ -243,6 +244,13 @@ class SessionQueueItem(BaseModel): started_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was started") completed_at: Optional[Union[datetime.datetime, str]] = Field(description="When this queue item was completed") queue_id: str = Field(description="The id of the queue with which this item is associated") + user_id: str = Field(default="system", description="The id of the user who created this queue item") + user_display_name: Optional[str] = Field( + default=None, description="The display name of the user who created this queue item, if available" + ) + user_email: Optional[str] = Field( + default=None, description="The email of the user who created this queue item, if available" + ) field_values: Optional[list[NodeFieldValue]] = Field( default=None, description="The field values that were used for this queue item" ) @@ -296,6 +304,12 @@ class SessionQueueStatus(BaseModel): failed: int = Field(..., description="Number of queue items with status 'error'") canceled: int = Field(..., description="Number of queue items with status 'canceled'") total: int = Field(..., description="Total number of queue items") + user_pending: Optional[int] = Field( + default=None, description="Number of queue items with status 'pending' for the current user" + ) + user_in_progress: Optional[int] = Field( + default=None, description="Number of queue items with status 'in_progress' for the current user" + ) class SessionQueueCountsByDestination(BaseModel): @@ -565,6 +579,7 @@ ValueToInsertTuple: TypeAlias = tuple[ str | None, # origin (optional) str | None, # destination (optional) int | None, # retried_from_item_id (optional, this is always None for new items) + str, # user_id ] """A type alias for the tuple of values to insert into the session queue table. @@ -573,7 +588,7 @@ ValueToInsertTuple: TypeAlias = tuple[ def prepare_values_to_insert( - queue_id: str, batch: Batch, priority: int, max_new_queue_items: int + queue_id: str, batch: Batch, priority: int, max_new_queue_items: int, user_id: str = "system" ) -> list[ValueToInsertTuple]: """ Given a batch, prepare the values to insert into the session queue table. The list of tuples can be used with an @@ -584,6 +599,7 @@ def prepare_values_to_insert( batch: The batch to prepare the values for priority: The priority of the queue items max_new_queue_items: The maximum number of queue items to insert + user_id: The user ID who is creating these queue items Returns: A list of tuples to insert into the session queue table. Each tuple contains the following values: @@ -597,6 +613,7 @@ def prepare_values_to_insert( - origin (optional) - destination (optional) - retried_from_item_id (optional, this is always None for new items) + - user_id """ # A tuple is a fast and memory-efficient way to store the values to insert. Previously, we used a NamedTuple, but @@ -626,6 +643,7 @@ def prepare_values_to_insert( batch.origin, batch.destination, None, + user_id, ) ) return values_to_insert diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py index 10a2c14e7a..4f46136fd7 100644 --- a/invokeai/app/services/session_queue/session_queue_sqlite.py +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -100,7 +100,9 @@ class SqliteSessionQueue(SessionQueueBase): priority = cast(Union[int, None], cursor.fetchone()[0]) or 0 return priority - async def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult: + async def enqueue_batch( + self, queue_id: str, batch: Batch, prepend: bool, user_id: str = "system" + ) -> EnqueueBatchResult: current_queue_size = self._get_current_queue_size(queue_id) max_queue_size = self.__invoker.services.configuration.max_queue_size max_new_queue_items = max_queue_size - current_queue_size @@ -119,14 +121,15 @@ class SqliteSessionQueue(SessionQueueBase): batch=batch, priority=priority, max_new_queue_items=max_new_queue_items, + user_id=user_id, ) enqueued_count = len(values_to_insert) with self._db.transaction() as cursor: cursor.executemany( """--sql - INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, values_to_insert, ) @@ -155,12 +158,16 @@ class SqliteSessionQueue(SessionQueueBase): with self._db.transaction() as cursor: cursor.execute( """--sql - SELECT * - FROM session_queue - WHERE status = 'pending' + SELECT + sq.*, + u.display_name as user_display_name, + u.email as user_email + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id + WHERE sq.status = 'pending' ORDER BY - priority DESC, - item_id ASC + sq.priority DESC, + sq.item_id ASC LIMIT 1 """ ) @@ -175,14 +182,18 @@ class SqliteSessionQueue(SessionQueueBase): with self._db.transaction() as cursor: cursor.execute( """--sql - SELECT * - FROM session_queue + SELECT + sq.*, + u.display_name as user_display_name, + u.email as user_email + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id WHERE - queue_id = ? - AND status = 'pending' + sq.queue_id = ? + AND sq.status = 'pending' ORDER BY - priority DESC, - created_at ASC + sq.priority DESC, + sq.created_at ASC LIMIT 1 """, (queue_id,), @@ -196,11 +207,15 @@ class SqliteSessionQueue(SessionQueueBase): with self._db.transaction() as cursor: cursor.execute( """--sql - SELECT * - FROM session_queue + SELECT + sq.*, + u.display_name as user_display_name, + u.email as user_email + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id WHERE - queue_id = ? - AND status = 'in_progress' + sq.queue_id = ? + AND sq.status = 'in_progress' LIMIT 1 """, (queue_id,), @@ -277,31 +292,41 @@ class SqliteSessionQueue(SessionQueueBase): is_full = cast(int, cursor.fetchone()[0]) >= max_queue_size return IsFullResult(is_full=is_full) - def clear(self, queue_id: str) -> ClearResult: + def clear(self, queue_id: str, user_id: Optional[str] = None) -> ClearResult: with self._db.transaction() as cursor: + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql + WHERE queue_id = ? + {user_filter} + """ + params: list[str] = [queue_id] + if user_id is not None: + params.append(user_id) cursor.execute( - """--sql + f"""--sql SELECT COUNT(*) FROM session_queue - WHERE queue_id = ? + {where} """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( - """--sql + f"""--sql DELETE FROM session_queue - WHERE queue_id = ? + {where} """, - (queue_id,), + tuple(params), ) self.__invoker.services.events.emit_queue_cleared(queue_id) return ClearResult(deleted=count) - def prune(self, queue_id: str) -> PruneResult: + def prune(self, queue_id: str, user_id: Optional[str] = None) -> PruneResult: with self._db.transaction() as cursor: - where = """--sql + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id = ? AND ( @@ -309,14 +334,19 @@ class SqliteSessionQueue(SessionQueueBase): OR status = 'failed' OR status = 'canceled' ) + {user_filter} """ + params = [queue_id] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -325,7 +355,7 @@ class SqliteSessionQueue(SessionQueueBase): FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) return PruneResult(deleted=count) @@ -369,10 +399,15 @@ class SqliteSessionQueue(SessionQueueBase): ) return queue_item - def cancel_by_batch_ids(self, queue_id: str, batch_ids: list[str]) -> CancelByBatchIDsResult: + def cancel_by_batch_ids( + self, queue_id: str, batch_ids: list[str], user_id: Optional[str] = None + ) -> CancelByBatchIDsResult: with self._db.transaction() as cursor: current_queue_item = self.get_current(queue_id) placeholders = ", ".join(["?" for _ in batch_ids]) + + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" where = f"""--sql WHERE queue_id == ? @@ -382,8 +417,12 @@ class SqliteSessionQueue(SessionQueueBase): AND status != 'failed' -- We will cancel the current item separately below - skip it here AND status != 'in_progress' + {user_filter} """ params = [queue_id] + batch_ids + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) @@ -402,15 +441,22 @@ class SqliteSessionQueue(SessionQueueBase): tuple(params), ) + # Handle current item separately - check ownership if user_id is provided if current_queue_item is not None and current_queue_item.batch_id in batch_ids: - self._set_queue_item_status(current_queue_item.item_id, "canceled") + if user_id is None or current_queue_item.user_id == user_id: + self._set_queue_item_status(current_queue_item.item_id, "canceled") return CancelByBatchIDsResult(canceled=count) - def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult: + def cancel_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> CancelByDestinationResult: with self._db.transaction() as cursor: current_queue_item = self.get_current(queue_id) - where = """--sql + + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id == ? AND destination == ? @@ -419,15 +465,19 @@ class SqliteSessionQueue(SessionQueueBase): AND status != 'failed' -- We will cancel the current item separately below - skip it here AND status != 'in_progress' + {user_filter} """ - params = (queue_id, destination) + params = [queue_id, destination] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - params, + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -436,55 +486,78 @@ class SqliteSessionQueue(SessionQueueBase): SET status = 'canceled' {where}; """, - params, + tuple(params), ) + + # Handle current item separately - check ownership if user_id is provided if current_queue_item is not None and current_queue_item.destination == destination: - self._set_queue_item_status(current_queue_item.item_id, "canceled") + if user_id is None or current_queue_item.user_id == user_id: + self._set_queue_item_status(current_queue_item.item_id, "canceled") + return CancelByDestinationResult(canceled=count) - def delete_by_destination(self, queue_id: str, destination: str) -> DeleteByDestinationResult: + def delete_by_destination( + self, queue_id: str, destination: str, user_id: Optional[str] = None + ) -> DeleteByDestinationResult: with self._db.transaction() as cursor: current_queue_item = self.get_current(queue_id) + + # Handle current item separately - check ownership if user_id is provided if current_queue_item is not None and current_queue_item.destination == destination: - self.cancel_queue_item(current_queue_item.item_id) - params = (queue_id, destination) + if user_id is None or current_queue_item.user_id == user_id: + self.cancel_queue_item(current_queue_item.item_id) + + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + params = [queue_id, destination] + if user_id is not None: + params.append(user_id) + cursor.execute( - """--sql + f"""--sql SELECT COUNT(*) FROM session_queue WHERE - queue_id = ? - AND destination = ?; + queue_id == ? + AND destination == ? + {user_filter} """, - params, + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( - """--sql - DELETE - FROM session_queue + f"""--sql + DELETE FROM session_queue WHERE - queue_id = ? - AND destination = ?; + queue_id == ? + AND destination == ? + {user_filter} """, - params, + tuple(params), ) return DeleteByDestinationResult(deleted=count) - def delete_all_except_current(self, queue_id: str) -> DeleteAllExceptCurrentResult: + def delete_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> DeleteAllExceptCurrentResult: with self._db.transaction() as cursor: - where = """--sql + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id == ? AND status == 'pending' + {user_filter} """ + params = [queue_id] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -493,7 +566,7 @@ class SqliteSessionQueue(SessionQueueBase): FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) return DeleteAllExceptCurrentResult(deleted=count) @@ -532,20 +605,27 @@ class SqliteSessionQueue(SessionQueueBase): self._set_queue_item_status(current_queue_item.item_id, "canceled") return CancelByQueueIDResult(canceled=count) - def cancel_all_except_current(self, queue_id: str) -> CancelAllExceptCurrentResult: + def cancel_all_except_current(self, queue_id: str, user_id: Optional[str] = None) -> CancelAllExceptCurrentResult: with self._db.transaction() as cursor: - where = """--sql + # Build WHERE clause with optional user_id filter + user_filter = "AND user_id = ?" if user_id is not None else "" + where = f"""--sql WHERE queue_id == ? AND status == 'pending' + {user_filter} """ + params = [queue_id] + if user_id is not None: + params.append(user_id) + cursor.execute( f"""--sql SELECT COUNT(*) FROM session_queue {where}; """, - (queue_id,), + tuple(params), ) count = cursor.fetchone()[0] cursor.execute( @@ -554,7 +634,7 @@ class SqliteSessionQueue(SessionQueueBase): SET status = 'canceled' {where}; """, - (queue_id,), + tuple(params), ) return CancelAllExceptCurrentResult(canceled=count) @@ -562,9 +642,13 @@ class SqliteSessionQueue(SessionQueueBase): with self._db.transaction() as cursor: cursor.execute( """--sql - SELECT * FROM session_queue - WHERE - item_id = ? + SELECT + sq.*, + u.display_name as user_display_name, + u.email as user_email + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id + WHERE sq.item_id = ? """, (item_id,), ) @@ -650,22 +734,26 @@ class SqliteSessionQueue(SessionQueueBase): """Gets all queue items that match the given parameters""" with self._db.transaction() as cursor: query = """--sql - SELECT * - FROM session_queue - WHERE queue_id = ? + SELECT + sq.*, + u.display_name as user_display_name, + u.email as user_email + FROM session_queue sq + LEFT JOIN users u ON sq.user_id = u.user_id + WHERE sq.queue_id = ? """ params: list[Union[str, int]] = [queue_id] if destination is not None: query += """---sql - AND destination = ? + AND sq.destination = ? """ params.append(destination) query += """--sql ORDER BY - priority DESC, - item_id ASC + sq.priority DESC, + sq.item_id ASC ; """ cursor.execute(query, params) @@ -693,8 +781,9 @@ class SqliteSessionQueue(SessionQueueBase): return ItemIdsResult(item_ids=item_ids, total_count=len(item_ids)) - def get_queue_status(self, queue_id: str) -> SessionQueueStatus: + def get_queue_status(self, queue_id: str, user_id: Optional[str] = None) -> SessionQueueStatus: with self._db.transaction() as cursor: + # Get total counts cursor.execute( """--sql SELECT status, count(*) @@ -706,9 +795,32 @@ class SqliteSessionQueue(SessionQueueBase): ) counts_result = cast(list[sqlite3.Row], cursor.fetchall()) + # Get user-specific counts if user_id is provided (using a single query with CASE) + user_counts_result = [] + if user_id is not None: + cursor.execute( + """--sql + SELECT status, count(*) + FROM session_queue + WHERE queue_id = ? AND user_id = ? + GROUP BY status + """, + (queue_id, user_id), + ) + user_counts_result = cast(list[sqlite3.Row], cursor.fetchall()) + current_item = self.get_current(queue_id=queue_id) total = sum(row[1] or 0 for row in counts_result) counts: dict[str, int] = {row[0]: row[1] for row in counts_result} + + # Process user-specific counts if available + user_pending = None + user_in_progress = None + if user_id is not None: + user_counts: dict[str, int] = {row[0]: row[1] for row in user_counts_result} + user_pending = user_counts.get("pending", 0) + user_in_progress = user_counts.get("in_progress", 0) + return SessionQueueStatus( queue_id=queue_id, item_id=current_item.item_id if current_item else None, @@ -720,6 +832,8 @@ class SqliteSessionQueue(SessionQueueBase): failed=counts.get("failed", 0), canceled=counts.get("canceled", 0), total=total, + user_pending=user_pending, + user_in_progress=user_in_progress, ) def get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus: @@ -822,6 +936,7 @@ class SqliteSessionQueue(SessionQueueBase): queue_item.origin, queue_item.destination, retried_from_item_id, + queue_item.user_id, ) values_to_insert.append(value_to_insert) @@ -829,8 +944,8 @@ class SqliteSessionQueue(SessionQueueBase): cursor.executemany( """--sql - INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, values_to_insert, ) diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py index 4ee43b29b7..67e3c99f1a 100644 --- a/invokeai/app/services/shared/invocation_context.py +++ b/invokeai/app/services/shared/invocation_context.py @@ -72,7 +72,7 @@ class InvocationContextInterface: class BoardsInterface(InvocationContextInterface): def create(self, board_name: str) -> BoardDTO: - """Creates a board. + """Creates a board for the current user. Args: board_name: The name of the board to create. @@ -80,7 +80,8 @@ class BoardsInterface(InvocationContextInterface): Returns: The created board DTO. """ - return self._services.boards.create(board_name) + user_id = self._data.queue_item.user_id + return self._services.boards.create(board_name, user_id) def get_dto(self, board_id: str) -> BoardDTO: """Gets a board DTO. @@ -94,13 +95,14 @@ class BoardsInterface(InvocationContextInterface): return self._services.boards.get_dto(board_id) def get_all(self) -> list[BoardDTO]: - """Gets all boards. + """Gets all boards accessible to the current user. Returns: - A list of all boards. + A list of all boards accessible to the current user. """ + user_id = self._data.queue_item.user_id return self._services.boards.get_all( - order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending + user_id, order_by=BoardRecordOrderBy.CreatedAt, direction=SQLiteDirection.Descending ) def add_image_to_board(self, board_id: str, image_name: str) -> None: @@ -228,6 +230,7 @@ class ImagesInterface(InvocationContextInterface): graph=graph_, session_id=self._data.queue_item.session_id, node_id=self._data.invocation.id, + user_id=self._data.queue_item.user_id, ) def get_pil(self, image_name: str, mode: IMAGE_MODES | None = None) -> Image: diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 6621170a97..645509f1dd 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -29,6 +29,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_23 import from invokeai.app.services.shared.sqlite_migrator.migrations.migration_24 import build_migration_24 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.sqlite_migrator_impl import SqliteMigrator @@ -75,6 +76,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_24(app_config=config, logger=logger)) 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.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py new file mode 100644 index 0000000000..b80ea073ef --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_27.py @@ -0,0 +1,366 @@ +"""Migration 27: Add multi-user support, per-user client state, and app settings. + +This migration adds the database schema for multi-user support, including: +- users table for user accounts +- user_sessions table for session management +- user_invitations table for invitation system +- shared_boards table for board sharing +- Adding user_id columns to existing tables for data ownership +- Restructuring client_state table to support per-user storage +- app_settings table for storing JWT secret and other app-level settings +""" + +import json +import secrets +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration27Callback: + """Migration to add multi-user support, per-user client state, and app settings.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_users_table(cursor) + self._create_user_sessions_table(cursor) + self._create_user_invitations_table(cursor) + self._create_shared_boards_table(cursor) + self._update_boards_table(cursor) + self._update_images_table(cursor) + self._update_workflows_table(cursor) + self._update_session_queue_table(cursor) + self._update_style_presets_table(cursor) + self._create_system_user(cursor) + self._update_client_state_table(cursor) + self._create_app_settings_table(cursor) + self._generate_jwt_secret(cursor) + + def _create_users_table(self, cursor: sqlite3.Cursor) -> None: + """Create users table.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_admin ON users(is_admin);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_is_active ON users(is_active);") + + cursor.execute(""" + CREATE TRIGGER IF NOT EXISTS tg_users_updated_at + AFTER UPDATE ON users FOR EACH ROW + BEGIN + UPDATE users SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE user_id = old.user_id; + END; + """) + + def _create_user_sessions_table(self, cursor: sqlite3.Cursor) -> None: + """Create user_sessions table for session management.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_sessions ( + session_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_activity_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_token_hash ON user_sessions(token_hash);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);") + + def _create_user_invitations_table(self, cursor: sqlite3.Cursor) -> None: + """Create user_invitations table for invitation system.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_invitations ( + invitation_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL, + invited_by TEXT NOT NULL, + invitation_code TEXT NOT NULL UNIQUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + expires_at DATETIME NOT NULL, + used_at DATETIME, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + FOREIGN KEY (invited_by) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_email ON user_invitations(email);") + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_user_invitations_invitation_code ON user_invitations(invitation_code);" + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_invitations_expires_at ON user_invitations(expires_at);") + + def _create_shared_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Create shared_boards table for board sharing.""" + cursor.execute(""" + CREATE TABLE IF NOT EXISTS shared_boards ( + board_id TEXT NOT NULL, + user_id TEXT NOT NULL, + can_edit BOOLEAN NOT NULL DEFAULT FALSE, + shared_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + PRIMARY KEY (board_id, user_id), + FOREIGN KEY (board_id) REFERENCES boards(board_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_user_id ON shared_boards(user_id);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_shared_boards_board_id ON shared_boards(board_id);") + + def _update_boards_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to boards table.""" + # Check if boards table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='boards';") + if cursor.fetchone() is None: + return + + # Check if user_id column exists + cursor.execute("PRAGMA table_info(boards);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE boards ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_user_id ON boards(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE boards ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_boards_is_public ON boards(is_public);") + + def _update_images_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id column to images table.""" + # Check if images table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(images);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE images ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);") + + def _update_workflows_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to workflows table.""" + # Check if workflows table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='workflows';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(workflows);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE workflows ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_user_id ON workflows(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE workflows ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_workflows_is_public ON workflows(is_public);") + + def _update_session_queue_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id column to session_queue table.""" + # Check if session_queue table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session_queue';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(session_queue);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE session_queue ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_session_queue_user_id ON session_queue(user_id);") + + def _update_style_presets_table(self, cursor: sqlite3.Cursor) -> None: + """Add user_id and is_public columns to style_presets table.""" + # Check if style_presets table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='style_presets';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(style_presets);") + columns = [row[1] for row in cursor.fetchall()] + + if "user_id" not in columns: + cursor.execute("ALTER TABLE style_presets ADD COLUMN user_id TEXT DEFAULT 'system';") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_user_id ON style_presets(user_id);") + + if "is_public" not in columns: + cursor.execute("ALTER TABLE style_presets ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT FALSE;") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_style_presets_is_public ON style_presets(is_public);") + + def _create_system_user(self, cursor: sqlite3.Cursor) -> None: + """Create system user for backward compatibility. + + The system user is NOT an admin - it's just used to own existing data + from before multi-user support was added. Real admin users should be + created through the /auth/setup endpoint. + """ + cursor.execute(""" + INSERT OR IGNORE INTO users (user_id, email, display_name, password_hash, is_admin, is_active) + VALUES ('system', 'system@system.invokeai', 'System', '', FALSE, TRUE); + """) + + def _update_client_state_table(self, cursor: sqlite3.Cursor) -> None: + """Restructure client_state table to support per-user storage.""" + # Check if client_state table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='client_state';") + if cursor.fetchone() is None: + # Table doesn't exist, create it with the new schema + cursor.execute( + """ + CREATE TABLE client_state ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """ + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);") + cursor.execute( + """ + CREATE TRIGGER tg_client_state_updated_at + AFTER UPDATE ON client_state + FOR EACH ROW + BEGIN + UPDATE client_state + SET updated_at = CURRENT_TIMESTAMP + WHERE user_id = OLD.user_id AND key = OLD.key; + END; + """ + ) + return + + # Table exists with old schema - migrate it + # Get existing data if the data column is present (it may be absent if an older + # version of migration 21 was deployed without the column) + cursor.execute("PRAGMA table_info(client_state);") + columns = [row[1] for row in cursor.fetchall()] + existing_data = {} + if "data" in columns: + cursor.execute("SELECT data FROM client_state WHERE id = 1;") + row = cursor.fetchone() + if row is not None: + try: + existing_data = json.loads(row[0]) + except (json.JSONDecodeError, TypeError): + # If data is corrupt, just start fresh + pass + + # Drop the old table + cursor.execute("DROP TABLE IF EXISTS client_state;") + + # Create new table with per-user schema + cursor.execute( + """ + CREATE TABLE client_state ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE + ); + """ + ) + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_client_state_user_id ON client_state(user_id);") + + cursor.execute( + """ + CREATE TRIGGER tg_client_state_updated_at + AFTER UPDATE ON client_state + FOR EACH ROW + BEGIN + UPDATE client_state + SET updated_at = CURRENT_TIMESTAMP + WHERE user_id = OLD.user_id AND key = OLD.key; + END; + """ + ) + + # Migrate existing data to 'system' user + for key, value in existing_data.items(): + cursor.execute( + """ + INSERT INTO client_state (user_id, key, value) + VALUES ('system', ?, ?); + """, + (key, value), + ) + + def _create_app_settings_table(self, cursor: sqlite3.Cursor) -> None: + """Create app_settings table for storing application-level configuration.""" + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT NOT NULL PRIMARY KEY, + value TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) + ); + """ + ) + + cursor.execute( + """ + CREATE TRIGGER IF NOT EXISTS tg_app_settings_updated_at + AFTER UPDATE ON app_settings + FOR EACH ROW + BEGIN + UPDATE app_settings SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE key = OLD.key; + END; + """ + ) + + def _generate_jwt_secret(self, cursor: sqlite3.Cursor) -> None: + """Generate and store a cryptographically secure JWT secret key. + + The secret is a 64-character hexadecimal string (256 bits of entropy), + which is suitable for HS256 JWT signing. + """ + # Check if JWT secret already exists + cursor.execute("SELECT value FROM app_settings WHERE key = 'jwt_secret';") + existing_secret = cursor.fetchone() + + if existing_secret is None: + # Generate a new cryptographically secure secret (256 bits) + jwt_secret = secrets.token_hex(32) # 32 bytes = 256 bits = 64 hex characters + + # Store in database + cursor.execute( + "INSERT INTO app_settings (key, value) VALUES ('jwt_secret', ?);", + (jwt_secret,), + ) + + +def build_migration_27() -> Migration: + """Builds the migration object for migrating from version 26 to version 27. + + This migration adds multi-user support, per-user client state, and app settings + (including a JWT secret) to the database schema. + """ + return Migration( + from_version=26, + to_version=27, + callback=Migration27Callback(), + ) diff --git a/invokeai/app/services/users/__init__.py b/invokeai/app/services/users/__init__.py new file mode 100644 index 0000000000..f497675950 --- /dev/null +++ b/invokeai/app/services/users/__init__.py @@ -0,0 +1 @@ +"""User service module.""" diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py new file mode 100644 index 0000000000..6587a2aa3a --- /dev/null +++ b/invokeai/app/services/users/users_base.py @@ -0,0 +1,126 @@ +"""Abstract base class for user service.""" + +from abc import ABC, abstractmethod + +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest + + +class UserServiceBase(ABC): + """High-level service for user management.""" + + @abstractmethod + def create(self, user_data: UserCreateRequest) -> UserDTO: + """Create a new user. + + Args: + user_data: User creation data + + Returns: + The created user + + Raises: + ValueError: If email already exists or password is weak + """ + pass + + @abstractmethod + def get(self, user_id: str) -> UserDTO | None: + """Get user by ID. + + Args: + user_id: The user ID + + Returns: + UserDTO if found, None otherwise + """ + pass + + @abstractmethod + def get_by_email(self, email: str) -> UserDTO | None: + """Get user by email. + + Args: + email: The email address + + Returns: + UserDTO if found, None otherwise + """ + pass + + @abstractmethod + def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: + """Update user. + + Args: + user_id: The user ID + changes: Fields to update + + Returns: + The updated user + + Raises: + ValueError: If user not found or password is weak + """ + pass + + @abstractmethod + def delete(self, user_id: str) -> None: + """Delete user. + + Args: + user_id: The user ID + + Raises: + ValueError: If user not found + """ + pass + + @abstractmethod + def authenticate(self, email: str, password: str) -> UserDTO | None: + """Authenticate user credentials. + + Args: + email: User email + password: User password + + Returns: + UserDTO if authentication successful, None otherwise + """ + pass + + @abstractmethod + def has_admin(self) -> bool: + """Check if any admin user exists. + + Returns: + True if at least one admin user exists, False otherwise + """ + pass + + @abstractmethod + def create_admin(self, user_data: UserCreateRequest) -> UserDTO: + """Create an admin user (for initial setup). + + Args: + user_data: User creation data + + Returns: + The created admin user + + Raises: + ValueError: If admin already exists or password is weak + """ + pass + + @abstractmethod + def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: + """List all users. + + Args: + limit: Maximum number of users to return + offset: Number of users to skip + + Returns: + List of users + """ + pass diff --git a/invokeai/app/services/users/users_common.py b/invokeai/app/services/users/users_common.py new file mode 100644 index 0000000000..c13150a336 --- /dev/null +++ b/invokeai/app/services/users/users_common.py @@ -0,0 +1,114 @@ +"""Common types and data models for user service.""" + +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator +from pydantic_core import PydanticCustomError + + +def validate_email_with_special_domains(email: str) -> str: + """Validate email address, allowing special-use domains like .local for testing. + + This validator first tries standard email validation using email-validator library. + If it fails due to special-use domains (like .local, .test, .localhost), it performs + a basic syntax check instead. This allows development/testing with non-routable domains + while still catching actual typos and malformed emails. + + Args: + email: The email address to validate + + Returns: + The validated email address (lowercased) + + Raises: + PydanticCustomError: If the email format is invalid + """ + try: + # Try standard email validation using email-validator + from email_validator import EmailNotValidError, validate_email + + result = validate_email(email, check_deliverability=False) + return result.normalized + except EmailNotValidError as e: + error_msg = str(e) + + # Check if the error is specifically about special-use/reserved domains or localhost + if ( + "special-use" in error_msg.lower() + or "reserved" in error_msg.lower() + or "should have a period" in error_msg.lower() + ): + # Perform basic email syntax validation + email = email.strip().lower() + + if "@" not in email: + raise PydanticCustomError( + "value_error", + "Email address must contain an @ symbol", + ) + + local_part, domain = email.rsplit("@", 1) + + if not local_part or not domain: + raise PydanticCustomError( + "value_error", + "Email address must have both local and domain parts", + ) + + # Allow localhost and domains with dots + if domain == "localhost" or "." in domain: + return email + + raise PydanticCustomError( + "value_error", + "Email domain must contain a dot or be 'localhost'", + ) + else: + # Re-raise other validation errors + raise PydanticCustomError( + "value_error", + f"Invalid email address: {error_msg}", + ) + + +class UserDTO(BaseModel): + """User data transfer object.""" + + user_id: str = Field(description="Unique user identifier") + email: str = Field(description="User email address") + display_name: str | None = Field(default=None, description="Display name") + is_admin: bool = Field(default=False, description="Whether user has admin privileges") + is_active: bool = Field(default=True, description="Whether user account is active") + created_at: datetime = Field(description="When the user was created") + updated_at: datetime = Field(description="When the user was last updated") + last_login_at: datetime | None = Field(default=None, description="When user last logged in") + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + + +class UserCreateRequest(BaseModel): + """Request to create a new user.""" + + email: str = Field(description="User email address") + display_name: str | None = Field(default=None, description="Display name") + password: str = Field(description="User password") + is_admin: bool = Field(default=False, description="Whether user should have admin privileges") + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + + +class UserUpdateRequest(BaseModel): + """Request to update a user.""" + + display_name: str | None = Field(default=None, description="Display name") + password: str | None = Field(default=None, description="New password") + is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges") + is_active: bool | None = Field(default=None, description="Whether user account should be active") diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py new file mode 100644 index 0000000000..36ccec9e7e --- /dev/null +++ b/invokeai/app/services/users/users_default.py @@ -0,0 +1,251 @@ +"""Default SQLite implementation of user service.""" + +import sqlite3 +from datetime import datetime, timezone +from uuid import uuid4 + +from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.users.users_base import UserServiceBase +from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, UserUpdateRequest + + +class UserService(UserServiceBase): + """SQLite-based user service.""" + + def __init__(self, db: SqliteDatabase): + """Initialize user service. + + Args: + db: SQLite database instance + """ + self._db = db + + def create(self, user_data: UserCreateRequest) -> UserDTO: + """Create a new user.""" + # Validate password strength + is_valid, error_msg = validate_password_strength(user_data.password) + if not is_valid: + raise ValueError(error_msg) + + # Check if email already exists + if self.get_by_email(user_data.email) is not None: + raise ValueError(f"User with email {user_data.email} already exists") + + user_id = str(uuid4()) + password_hash = hash_password(user_data.password) + + with self._db.transaction() as cursor: + try: + cursor.execute( + """ + INSERT INTO users (user_id, email, display_name, password_hash, is_admin) + VALUES (?, ?, ?, ?, ?) + """, + (user_id, user_data.email, user_data.display_name, password_hash, user_data.is_admin), + ) + except sqlite3.IntegrityError as e: + raise ValueError(f"Failed to create user: {e}") from e + + user = self.get(user_id) + if user is None: + raise RuntimeError("Failed to retrieve created user") + return user + + def get(self, user_id: str) -> UserDTO | None: + """Get user by ID.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + WHERE user_id = ? + """, + (user_id,), + ) + row = cursor.fetchone() + + if row is None: + return None + + return UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[3]), + is_active=bool(row[4]), + created_at=datetime.fromisoformat(row[5]), + updated_at=datetime.fromisoformat(row[6]), + last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, + ) + + def get_by_email(self, email: str) -> UserDTO | None: + """Get user by email.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + WHERE email = ? + """, + (email,), + ) + row = cursor.fetchone() + + if row is None: + return None + + return UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[3]), + is_active=bool(row[4]), + created_at=datetime.fromisoformat(row[5]), + updated_at=datetime.fromisoformat(row[6]), + last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, + ) + + def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO: + """Update user.""" + # Check if user exists + user = self.get(user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + + # Validate password if provided + if changes.password is not None: + is_valid, error_msg = validate_password_strength(changes.password) + if not is_valid: + raise ValueError(error_msg) + + # Build update query dynamically based on provided fields + updates: list[str] = [] + params: list[str | bool | int] = [] + + if changes.display_name is not None: + updates.append("display_name = ?") + params.append(changes.display_name) + + if changes.password is not None: + updates.append("password_hash = ?") + params.append(hash_password(changes.password)) + + if changes.is_admin is not None: + updates.append("is_admin = ?") + params.append(changes.is_admin) + + if changes.is_active is not None: + updates.append("is_active = ?") + params.append(changes.is_active) + + if not updates: + return user + + params.append(user_id) + query = f"UPDATE users SET {', '.join(updates)} WHERE user_id = ?" + + with self._db.transaction() as cursor: + cursor.execute(query, params) + + updated_user = self.get(user_id) + if updated_user is None: + raise RuntimeError("Failed to retrieve updated user") + return updated_user + + def delete(self, user_id: str) -> None: + """Delete user.""" + user = self.get(user_id) + if user is None: + raise ValueError(f"User {user_id} not found") + + with self._db.transaction() as cursor: + cursor.execute("DELETE FROM users WHERE user_id = ?", (user_id,)) + + def authenticate(self, email: str, password: str) -> UserDTO | None: + """Authenticate user credentials.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, password_hash, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + WHERE email = ? + """, + (email,), + ) + row = cursor.fetchone() + + if row is None: + return None + + password_hash = row[3] + if not verify_password(password, password_hash): + return None + + # Update last login time + with self._db.transaction() as cursor: + cursor.execute( + "UPDATE users SET last_login_at = ? WHERE user_id = ?", + (datetime.now(timezone.utc).isoformat(), row[0]), + ) + + return UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[4]), + is_active=bool(row[5]), + created_at=datetime.fromisoformat(row[6]), + updated_at=datetime.fromisoformat(row[7]), + last_login_at=datetime.now(timezone.utc), + ) + + def has_admin(self) -> bool: + """Check if any admin user exists.""" + with self._db.transaction() as cursor: + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE") + row = cursor.fetchone() + count = row[0] if row else 0 + return bool(count > 0) + + def create_admin(self, user_data: UserCreateRequest) -> UserDTO: + """Create an admin user (for initial setup).""" + if self.has_admin(): + raise ValueError("Admin user already exists") + + # Force is_admin to True + admin_data = UserCreateRequest( + email=user_data.email, + display_name=user_data.display_name, + password=user_data.password, + is_admin=True, + ) + return self.create(admin_data) + + def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: + """List all users.""" + with self._db.transaction() as cursor: + cursor.execute( + """ + SELECT user_id, email, display_name, is_admin, is_active, created_at, updated_at, last_login_at + FROM users + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = cursor.fetchall() + + return [ + UserDTO( + user_id=row[0], + email=row[1], + display_name=row[2], + is_admin=bool(row[3]), + is_active=bool(row[4]), + created_at=datetime.fromisoformat(row[5]), + updated_at=datetime.fromisoformat(row[6]), + last_login_at=datetime.fromisoformat(row[7]) if row[7] else None, + ) + for row in rows + ] diff --git a/invokeai/app/util/user_management.py b/invokeai/app/util/user_management.py new file mode 100644 index 0000000000..24b1fe91ab --- /dev/null +++ b/invokeai/app/util/user_management.py @@ -0,0 +1,579 @@ +"""User management command entry points for InvokeAI. + +These functions are registered as console scripts in pyproject.toml and can be +called from the command line after installing the package: + + invoke-useradd -- add a user + invoke-userdel -- delete a user + invoke-userlist -- list users + invoke-usermod -- modify a user +""" + +import argparse +import getpass +import json +import os +import sys + +_root_help = ( + "Path to the InvokeAI root directory. If omitted, the root is resolved in this order: " + "the $INVOKEAI_ROOT environment variable, the active virtual environment's parent directory, " + "or $HOME/invokeai." +) + +# --------------------------------------------------------------------------- +# useradd +# --------------------------------------------------------------------------- + + +def _add_user_interactive() -> bool: + """Add a user interactively by prompting for details.""" + from invokeai.app.services.auth.password_utils import validate_password_strength + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_common import UserCreateRequest + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + print("=== Add InvokeAI User ===\n") + + email = input("Email address: ").strip() + if not email: + print("Error: Email is required") + return False + + display_name = input("Display name (optional): ").strip() or None + + while True: + password = getpass.getpass("Password: ") + password_confirm = getpass.getpass("Confirm password: ") + + if password != password_confirm: + print("Error: Passwords do not match. Please try again.\n") + continue + + is_valid, error_msg = validate_password_strength(password) + if not is_valid: + print(f"Error: {error_msg}\n") + continue + + break + + is_admin_input = input("Make this user an administrator? (y/N): ").strip().lower() + is_admin = is_admin_input in ("y", "yes") + + try: + config = get_config() + db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger()) + user_service = UserService(db) + + user_data = UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin) + user = user_service.create(user_data) + + print("\n✅ User created successfully!") + print(f" User ID: {user.user_id}") + print(f" Email: {user.email}") + print(f" Display Name: {user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if user.is_admin else 'No'}") + print(f" Active: {'Yes' if user.is_active else 'No'}") + return True + + except ValueError as e: + print(f"\n❌ Error: {e}") + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + + traceback.print_exc() + return False + + +def _add_user_cli(email: str, password: str, display_name: str | None = None, is_admin: bool = False) -> bool: + """Add a user via CLI arguments.""" + from invokeai.app.services.auth.password_utils import validate_password_strength + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_common import UserCreateRequest + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + is_valid, error_msg = validate_password_strength(password) + if not is_valid: + print(f"❌ Password validation failed: {error_msg}") + return False + + try: + config = get_config() + db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger()) + user_service = UserService(db) + + user_data = UserCreateRequest(email=email, display_name=display_name, password=password, is_admin=is_admin) + user = user_service.create(user_data) + + print("✅ User created successfully!") + print(f" User ID: {user.user_id}") + print(f" Email: {user.email}") + print(f" Display Name: {user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if user.is_admin else 'No'}") + print(f" Active: {'Yes' if user.is_active else 'No'}") + return True + + except ValueError as e: + print(f"❌ Error: {e}") + return False + except Exception as e: + print(f"❌ Unexpected error: {e}") + import traceback + + traceback.print_exc() + return False + + +def useradd() -> None: + """Entry point for ``invoke-useradd``.""" + parser = argparse.ArgumentParser( + description="Add a user to the InvokeAI database", + epilog="If no arguments are provided, the script will run in interactive mode.", + ) + parser.add_argument("--root", "-r", help=_root_help) + parser.add_argument("--email", "-e", help="User email address") + parser.add_argument("--password", "-p", help="User password") + parser.add_argument("--name", "-n", help="User display name (optional)") + parser.add_argument("--admin", "-a", action="store_true", help="Make user an administrator") + + args = parser.parse_args() + + if args.root: + os.environ["INVOKEAI_ROOT"] = args.root + + if args.email or args.password: + if not args.email or not args.password: + print("❌ Error: Both --email and --password are required when using CLI mode") + print(" Run without arguments for interactive mode") + sys.exit(1) + success = _add_user_cli(args.email, args.password, args.name, args.admin) + else: + success = _add_user_interactive() + + sys.exit(0 if success else 1) + + +# --------------------------------------------------------------------------- +# userdel +# --------------------------------------------------------------------------- + + +def _delete_user_interactive() -> bool: + """Delete a user interactively by prompting for email.""" + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + print("=== Delete InvokeAI User ===\n") + + email = input("Email address of user to delete: ").strip() + if not email: + print("Error: Email is required") + return False + + try: + config = get_config() + db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger()) + user_service = UserService(db) + + user = user_service.get_by_email(email) + if not user: + print(f"\n❌ Error: No user found with email '{email}'") + return False + + print("\nUser to delete:") + print(f" User ID: {user.user_id}") + print(f" Email: {user.email}") + print(f" Display Name: {user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if user.is_admin else 'No'}") + print(f" Active: {'Yes' if user.is_active else 'No'}") + + confirm = input("\n⚠️ Are you sure you want to delete this user? (yes/no): ").strip().lower() + if confirm not in ("yes", "y"): + print("Deletion cancelled.") + return False + + user_service.delete(user.user_id) + print("\n✅ User deleted successfully!") + return True + + except ValueError as e: + print(f"\n❌ Error: {e}") + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + + traceback.print_exc() + return False + + +def _delete_user_cli(email: str, force: bool = False) -> bool: + """Delete a user via CLI arguments.""" + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + try: + config = get_config() + db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger()) + user_service = UserService(db) + + user = user_service.get_by_email(email) + if not user: + print(f"❌ Error: No user found with email '{email}'") + return False + + if not force: + print("User to delete:") + print(f" User ID: {user.user_id}") + print(f" Email: {user.email}") + print(f" Display Name: {user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if user.is_admin else 'No'}") + print(f" Active: {'Yes' if user.is_active else 'No'}") + + confirm = input("\n⚠️ Are you sure you want to delete this user? (yes/no): ").strip().lower() + if confirm not in ("yes", "y"): + print("Deletion cancelled.") + return False + + user_service.delete(user.user_id) + print("✅ User deleted successfully!") + return True + + except ValueError as e: + print(f"❌ Error: {e}") + return False + except Exception as e: + print(f"❌ Unexpected error: {e}") + import traceback + + traceback.print_exc() + return False + + +def userdel() -> None: + """Entry point for ``invoke-userdel``.""" + parser = argparse.ArgumentParser( + description="Delete a user from the InvokeAI database", + epilog="If no arguments are provided, the script will run in interactive mode.", + ) + parser.add_argument("--root", "-r", help=_root_help) + parser.add_argument("--email", "-e", help="User email address") + parser.add_argument("--force", "-f", action="store_true", help="Delete without confirmation prompt") + + args = parser.parse_args() + + if args.root: + os.environ["INVOKEAI_ROOT"] = args.root + + if args.email: + success = _delete_user_cli(args.email, args.force) + else: + success = _delete_user_interactive() + + sys.exit(0 if success else 1) + + +# --------------------------------------------------------------------------- +# userlist +# --------------------------------------------------------------------------- + + +def _list_users_table() -> bool: + """List all users in a formatted table.""" + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + config = get_config() + logger = InvokeAILogger.get_logger(config=config) + db = SqliteDatabase(config.db_path, logger) + user_service = UserService(db) + + try: + users = user_service.list_users() + + if not users: + print("No users found in database.") + return True + + print("\n=== InvokeAI Users ===\n") + print(f"{'User ID':<36} {'Email':<30} {'Display Name':<20} {'Admin':<8} {'Active':<8}") + print("-" * 108) + + for user in users: + user_id = user.user_id + email = user.email[:29] if len(user.email) > 29 else user.email + raw_name = user.display_name or "" + name = raw_name[:19] if len(raw_name) > 19 else raw_name + is_admin = "Yes" if user.is_admin else "No" + is_active = "Yes" if user.is_active else "No" + print(f"{user_id:<36} {email:<30} {name:<20} {is_admin:<8} {is_active:<8}") + + print(f"\nTotal users: {len(users)}") + return True + + except Exception as e: + print(f"Error listing users: {e}") + return False + + +def _list_users_json() -> bool: + """List all users in JSON format.""" + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + config = get_config() + logger = InvokeAILogger.get_logger(config=config) + db = SqliteDatabase(config.db_path, logger) + user_service = UserService(db) + + try: + users = user_service.list_users() + + users_data = [ + { + "id": user.user_id, + "email": user.email, + "name": user.display_name, + "is_admin": user.is_admin, + "is_active": user.is_active, + } + for user in users + ] + + print(json.dumps(users_data, indent=2)) + return True + + except Exception as e: + print(f'{{"error": "{e}"}}', file=sys.stderr) + return False + + +def userlist() -> None: + """Entry point for ``invoke-userlist``.""" + parser = argparse.ArgumentParser( + description="List users from the InvokeAI database", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + invoke-userlist + invoke-userlist --json + """, + ) + parser.add_argument("--root", "-r", help=_root_help) + parser.add_argument( + "--json", + action="store_true", + help="Output users in JSON format instead of table", + ) + + args = parser.parse_args() + + if args.root: + os.environ["INVOKEAI_ROOT"] = args.root + + success = _list_users_json() if args.json else _list_users_table() + sys.exit(0 if success else 1) + + +# --------------------------------------------------------------------------- +# usermod +# --------------------------------------------------------------------------- + + +def _modify_user_interactive() -> bool: + """Modify a user interactively by prompting for details.""" + from invokeai.app.services.auth.password_utils import validate_password_strength + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_common import UserUpdateRequest + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + print("=== Modify InvokeAI User ===\n") + + email = input("Email address of user to modify: ").strip() + if not email: + print("Error: Email is required") + return False + + try: + config = get_config() + db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger()) + user_service = UserService(db) + + user = user_service.get_by_email(email) + if not user: + print(f"\n❌ Error: No user found with email '{email}'") + return False + + print("\nCurrent user details:") + print(f" User ID: {user.user_id}") + print(f" Email: {user.email}") + print(f" Display Name: {user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if user.is_admin else 'No'}") + print(f" Active: {'Yes' if user.is_active else 'No'}") + + print("\n--- What would you like to change? (leave blank to keep current value) ---\n") + + new_name = input(f"New display name [{user.display_name or '(not set)'}]: ").strip() + display_name = new_name if new_name else None + + change_password = input("Change password? (y/N): ").strip().lower() + password = None + if change_password in ("y", "yes"): + while True: + password = getpass.getpass("New password: ") + if not password: + print("Keeping existing password.") + password = None + break + + password_confirm = getpass.getpass("Confirm new password: ") + + if password != password_confirm: + print("Error: Passwords do not match. Please try again.\n") + continue + + is_valid, error_msg = validate_password_strength(password) + if not is_valid: + print(f"Error: {error_msg}\n") + continue + + break + + change_admin = input("Change admin status? (y/N): ").strip().lower() + is_admin = None + if change_admin in ("y", "yes"): + is_admin_input = ( + input(f"Make administrator? [current: {'Yes' if user.is_admin else 'No'}] (y/N): ").strip().lower() + ) + is_admin = is_admin_input in ("y", "yes") + + if display_name is None and password is None and is_admin is None: + print("\nNo changes requested. User not modified.") + return True + + changes = UserUpdateRequest(display_name=display_name, password=password, is_admin=is_admin) + updated_user = user_service.update(user.user_id, changes) + + print("\n✅ User updated successfully!") + print(f" User ID: {updated_user.user_id}") + print(f" Email: {updated_user.email}") + print(f" Display Name: {updated_user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if updated_user.is_admin else 'No'}") + print(f" Active: {'Yes' if updated_user.is_active else 'No'}") + return True + + except ValueError as e: + print(f"\n❌ Error: {e}") + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + + traceback.print_exc() + return False + + +def _modify_user_cli( + email: str, + display_name: str | None = None, + password: str | None = None, + is_admin: bool | None = None, +) -> bool: + """Modify a user via CLI arguments.""" + from invokeai.app.services.auth.password_utils import validate_password_strength + from invokeai.app.services.config import get_config + from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + from invokeai.app.services.users.users_common import UserUpdateRequest + from invokeai.app.services.users.users_default import UserService + from invokeai.backend.util.logging import InvokeAILogger + + if password is not None: + is_valid, error_msg = validate_password_strength(password) + if not is_valid: + print(f"❌ Password validation failed: {error_msg}") + return False + + try: + config = get_config() + db = SqliteDatabase(config.db_path, InvokeAILogger.get_logger()) + user_service = UserService(db) + + user = user_service.get_by_email(email) + if not user: + print(f"❌ Error: No user found with email '{email}'") + return False + + if display_name is None and password is None and is_admin is None: + print("❌ Error: No changes specified. Use --name, --password, --admin, or --no-admin") + return False + + changes = UserUpdateRequest(display_name=display_name, password=password, is_admin=is_admin) + updated_user = user_service.update(user.user_id, changes) + + print("✅ User updated successfully!") + print(f" User ID: {updated_user.user_id}") + print(f" Email: {updated_user.email}") + print(f" Display Name: {updated_user.display_name or '(not set)'}") + print(f" Admin: {'Yes' if updated_user.is_admin else 'No'}") + print(f" Active: {'Yes' if updated_user.is_active else 'No'}") + return True + + except ValueError as e: + print(f"❌ Error: {e}") + return False + except Exception as e: + print(f"❌ Unexpected error: {e}") + import traceback + + traceback.print_exc() + return False + + +def usermod() -> None: + """Entry point for ``invoke-usermod``.""" + parser = argparse.ArgumentParser( + description="Modify a user in the InvokeAI database", + epilog="If no arguments are provided, the script will run in interactive mode.", + ) + parser.add_argument("--root", "-r", help=_root_help) + parser.add_argument("--email", "-e", help="User email address") + parser.add_argument("--name", "-n", help="New display name") + parser.add_argument("--password", "-p", help="New password") + + admin_group = parser.add_mutually_exclusive_group() + admin_group.add_argument("--admin", "-a", action="store_true", help="Grant administrator privileges") + admin_group.add_argument("--no-admin", dest="no_admin", action="store_true", help="Remove administrator privileges") + + args = parser.parse_args() + + if args.root: + os.environ["INVOKEAI_ROOT"] = args.root + + is_admin = None + if args.admin: + is_admin = True + elif args.no_admin: + is_admin = False + + if args.email: + success = _modify_user_cli(args.email, args.name, args.password, is_admin) + else: + success = _modify_user_interactive() + + sys.exit(0 if success else 1) diff --git a/invokeai/frontend/web/knip.ts b/invokeai/frontend/web/knip.ts index 0880044a29..64dcd05485 100644 --- a/invokeai/frontend/web/knip.ts +++ b/invokeai/frontend/web/knip.ts @@ -15,6 +15,9 @@ const config: KnipConfig = { // Will be using this 'src/common/hooks/useAsyncState.ts', 'src/app/store/use-debounced-app-selector.ts', + // Auth features - exports will be used in follow-up phases + 'src/features/auth/**', + 'src/services/api/endpoints/auth.ts', ], ignoreBinaries: ['only-allow'], ignoreDependencies: ['magic-string'], diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index f9adaa28ef..af8476528d 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -53481,6 +53481,36 @@ } ], "description": "The workflow associated with this queue item" + }, + "user_id": { + "type": "string", + "title": "User Id", + "description": "The id of the user who created this queue item", + "default": "system" + }, + "user_display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Display Name", + "description": "The display name of the user who created this queue item, if available" + }, + "user_email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "User Email", + "description": "The email of the user who created this queue item, if available" } }, "type": "object", @@ -53571,6 +53601,30 @@ "type": "integer", "title": "Total", "description": "Total number of queue items" + }, + "user_pending": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "User Pending", + "description": "Number of queue items with status 'pending' for the current user" + }, + "user_in_progress": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "User In Progress", + "description": "Number of queue items with status 'in_progress' for the current user" } }, "type": "object", @@ -60983,6 +61037,45 @@ "output": { "$ref": "#/components/schemas/ZImageConditioningOutput" } + }, + "UserDTO": { + "type": "object", + "required": ["user_id", "email", "is_admin", "is_active"], + "properties": { + "user_id": { + "type": "string", + "title": "User Id", + "description": "The user ID" + }, + "email": { + "type": "string", + "title": "Email", + "description": "The user email" + }, + "display_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name", + "description": "The user display name" + }, + "is_admin": { + "type": "boolean", + "title": "Is Admin", + "description": "Whether the user is an admin" + }, + "is_active": { + "type": "boolean", + "title": "Is Active", + "description": "Whether the user is active" + } + }, + "title": "UserDTO" } } } diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 118fd330d0..da4e31142f 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -89,6 +89,7 @@ "react-icons": "^5.5.0", "react-redux": "9.2.0", "react-resizable-panels": "^3.0.3", + "react-router-dom": "^7.12.0", "react-textarea-autosize": "^8.5.9", "react-use": "^17.6.0", "react-virtuoso": "^4.13.0", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index bc37d62217..3f94ba7d69 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: react-resizable-panels: specifier: ^3.0.3 version: 3.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-router-dom: + specifier: ^7.12.0 + version: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-textarea-autosize: specifier: ^8.5.9 version: 8.5.9(@types/react@18.3.23)(react@18.3.1) @@ -1993,6 +1996,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -3459,6 +3466,23 @@ packages: react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-router-dom@7.12.0: + resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.12.0: + resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-select@5.10.2: resolution: {integrity: sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==} peerDependencies: @@ -3675,6 +3699,9 @@ packages: resolution: {integrity: sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==} engines: {node: '>=18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6120,6 +6147,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -7707,6 +7736,20 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-router-dom@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-router@7.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + cookie: 1.1.1 + react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react-select@5.10.2(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.28.3 @@ -7982,6 +8025,8 @@ snapshots: dependencies: type-fest: 4.41.0 + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9968532303..c28df6ee38 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -15,6 +15,44 @@ "uploadImage": "Upload Image", "uploadImages": "Upload Image(s)" }, + "auth": { + "login": { + "title": "Sign In to InvokeAI", + "email": "Email", + "emailPlaceholder": "Email", + "password": "Password", + "passwordPlaceholder": "Password", + "rememberMe": "Remember me for 7 days", + "signIn": "Sign In", + "signingIn": "Signing in...", + "loginFailed": "Login failed. Please check your credentials." + }, + "setup": { + "title": "Welcome to InvokeAI", + "subtitle": "Set up your administrator account to get started", + "email": "Email", + "emailPlaceholder": "admin@example.com", + "emailHelper": "This will be your username for signing in", + "displayName": "Display Name", + "displayNamePlaceholder": "Administrator", + "displayNameHelper": "Your name as it will appear in the application", + "password": "Password", + "passwordPlaceholder": "Password", + "passwordHelper": "Must be at least 8 characters with uppercase, lowercase, and numbers", + "passwordTooShort": "Password must be at least 8 characters long", + "passwordMissingRequirements": "Password must contain uppercase, lowercase, and numbers", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Confirm Password", + "passwordsDoNotMatch": "Passwords do not match", + "createAccount": "Create Administrator Account", + "creatingAccount": "Setting up...", + "setupFailed": "Setup failed. Please try again." + }, + "userMenu": "User Menu", + "admin": "Admin", + "logout": "Logout", + "adminOnlyFeature": "This feature is only available to administrators." + }, "boards": { "addBoard": "Add Board", "addPrivateBoard": "Add Private Board", @@ -272,6 +310,7 @@ "cancelTooltip": "Cancel Current Item", "cancelSucceeded": "Item Canceled", "cancelFailed": "Problem Canceling Item", + "cancelFailedAccessDenied": "Problem Canceling Item: Access Denied", "retrySucceeded": "Item Retried", "retryFailed": "Problem Retrying Item", "confirm": "Confirm", @@ -283,6 +322,7 @@ "clearTooltip": "Cancel and Clear All Items", "clearSucceeded": "Queue Cleared", "clearFailed": "Problem Clearing Queue", + "clearFailedAccessDenied": "Problem Clearing Queue: Access Denied", "cancelBatch": "Cancel Batch", "cancelItem": "Cancel Item", "retryItem": "Retry Item", @@ -304,6 +344,7 @@ "canceled": "Canceled", "completedIn": "Completed in", "batch": "Batch", + "user": "User", "origin": "Origin", "destination": "Dest", "upscaling": "Upscaling", @@ -313,6 +354,8 @@ "other": "Other", "gallery": "Gallery", "batchFieldValues": "Batch Field Values", + "fieldValuesHidden": "", + "cannotViewDetails": "You do not have permission to view the details of this queue item", "item": "Item", "session": "Session", "notReady": "Unable to Queue", @@ -1093,6 +1136,7 @@ "loraTriggerPhrases": "LoRA Trigger Phrases", "mainModelTriggerPhrases": "Main Model Trigger Phrases", "selectAll": "Select All", + "selectModelToView": "Select a model to view its details", "typePhraseHere": "Type phrase here", "t5Encoder": "T5 Encoder", "qwen3Encoder": "Qwen3 Encoder", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index bfe8e231c6..678acc7de1 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -1,13 +1,18 @@ -import { Box } from '@invoke-ai/ui-library'; +import { Box, Center, Spinner } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { GlobalHookIsolator } from 'app/components/GlobalHookIsolator'; import { GlobalModalIsolator } from 'app/components/GlobalModalIsolator'; import { clearStorage } from 'app/store/enhancers/reduxRemember/driver'; import Loading from 'common/components/Loading/Loading'; +import { AdministratorSetup } from 'features/auth/components/AdministratorSetup'; +import { LoginPage } from 'features/auth/components/LoginPage'; +import { ProtectedRoute } from 'features/auth/components/ProtectedRoute'; import { AppContent } from 'features/ui/components/AppContent'; import { navigationApi } from 'features/ui/layouts/navigation-api'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { Route, Routes, useNavigate } from 'react-router-dom'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import AppErrorBoundaryFallback from './AppErrorBoundaryFallback'; import ThemeLocaleProvider from './ThemeLocaleProvider'; @@ -18,14 +23,67 @@ const errorBoundaryOnReset = () => { return false; }; -const App = () => { +const MainApp = () => { const isNavigationAPIConnected = useStore(navigationApi.$isConnected); + return ( + + {isNavigationAPIConnected ? : } + + ); +}; + +const SetupChecker = () => { + const { data, isLoading } = useGetSetupStatusQuery(); + const navigate = useNavigate(); + + // Check if user is already authenticated + const token = localStorage.getItem('auth_token'); + const isAuthenticated = !!token; + + useEffect(() => { + if (!isLoading && data) { + // If multiuser mode is disabled, go directly to the app + if (!data.multiuser_enabled) { + navigate('/app', { replace: true }); + } else if (isAuthenticated) { + // In multiuser mode, check authentication + navigate('/app', { replace: true }); + } else if (data.setup_required) { + navigate('/setup', { replace: true }); + } else { + navigate('/login', { replace: true }); + } + } + }, [data, isLoading, navigate, isAuthenticated]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return null; +}; + +const App = () => { return ( - - {isNavigationAPIConnected ? : } - + + } /> + } /> + } /> + + + + } + /> + diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 775a4c7a96..f3d9c4bb28 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -7,6 +7,7 @@ import { createStore } from 'app/store/store'; import Loading from 'common/components/Loading/Loading'; import React, { lazy, memo, useEffect, useState } from 'react'; import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; /* * We need to configure logging before anything else happens - useLayoutEffect ensures we set this at the first @@ -51,9 +52,11 @@ const InvokeAIUI = () => { return ( - }> - - + + }> + + + ); diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts index 9e67770b43..fdb25b37d2 100644 --- a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts @@ -68,10 +68,26 @@ const getIdbKey = (key: string) => { return `${IDB_STORAGE_PREFIX}${key}`; }; +// Helper to get auth headers for client_state requests +const getAuthHeaders = (): Record => { + const headers: Record = {}; + // Safe access to localStorage (not available in Node.js test environment) + if (typeof window !== 'undefined' && window.localStorage) { + const token = localStorage.getItem('auth_token'); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + } + return headers; +}; + const getItem = async (key: string) => { try { const url = getUrl('get_by_key', key); - const res = await fetch(url, { method: 'GET' }); + const res = await fetch(url, { + method: 'GET', + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(`Response status: ${res.status}`); } @@ -130,7 +146,11 @@ const setItem = async (key: string, value: string) => { } log.trace({ key, last: lastPersistedState.get(key), next: value }, `Persisting state for ${key}`); const url = getUrl('set_by_key', key); - const res = await fetch(url, { method: 'POST', body: value }); + const res = await fetch(url, { + method: 'POST', + body: value, + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(`Response status: ${res.status}`); } @@ -158,7 +178,10 @@ export const clearStorage = async () => { try { persistRefCount++; const url = getUrl('delete'); - const res = await fetch(url, { method: 'POST' }); + const res = await fetch(url, { + method: 'POST', + headers: getAuthHeaders(), + }); if (!res.ok) { throw new Error(`Response status: ${res.status}`); } diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 232ab09147..8f077baaea 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -19,6 +19,7 @@ import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMi import { deepClone } from 'common/util/deepClone'; import { merge } from 'es-toolkit'; import { omit, pick } from 'es-toolkit/compat'; +import { authSliceConfig } from 'features/auth/store/authSlice'; import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/slice'; import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice'; @@ -61,6 +62,7 @@ const log = logger('system'); // When adding a slice, add the config to the SLICE_CONFIGS object below, then add the reducer to ALL_REDUCERS. const SLICE_CONFIGS = { + [authSliceConfig.slice.reducerPath]: authSliceConfig, [canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig, [canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig, [canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig, @@ -87,6 +89,7 @@ const SLICE_CONFIGS = { // Remember to wrap undoable reducers in `undoable()`! const ALL_REDUCERS = { [api.reducerPath]: api.reducer, + [authSliceConfig.slice.reducerPath]: authSliceConfig.slice.reducer, [canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer, [canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer, [canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig.slice.reducer, diff --git a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx new file mode 100644 index 0000000000..9827a4d976 --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx @@ -0,0 +1,246 @@ +import { + Box, + Button, + Center, + Flex, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Grid, + GridItem, + Heading, + Input, + Spinner, + Text, + VStack, +} from '@invoke-ai/ui-library'; +import type { ChangeEvent, FormEvent } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useGetSetupStatusQuery, useSetupMutation } from 'services/api/endpoints/auth'; + +const validatePasswordStrength = ( + password: string, + t: (key: string) => string +): { isValid: boolean; message: string } => { + if (password.length < 8) { + return { isValid: false, message: t('auth.setup.passwordTooShort') }; + } + + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + + if (!hasUpper || !hasLower || !hasDigit) { + return { + isValid: false, + message: t('auth.setup.passwordMissingRequirements'), + }; + } + + return { isValid: true, message: '' }; +}; + +export const AdministratorSetup = memo(() => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [setup, { isLoading, error }] = useSetupMutation(); + const { data: setupStatus, isLoading: isLoadingSetup } = useGetSetupStatusQuery(); + + // Redirect to app if multiuser mode is disabled + useEffect(() => { + if (!isLoadingSetup && setupStatus && !setupStatus.multiuser_enabled) { + navigate('/app', { replace: true }); + } + }, [setupStatus, isLoadingSetup, navigate]); + + const passwordValidation = validatePasswordStrength(password, t); + const passwordsMatch = password === confirmPassword; + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + + if (!passwordValidation.isValid) { + return; + } + + if (!passwordsMatch) { + return; + } + + try { + const result = await setup({ email, display_name: displayName, password }).unwrap(); + if (result.success) { + // Auto-login after setup - need to call login API + // For now, just redirect to login page + window.location.href = '/login'; + } + } catch { + // Error is handled by RTK Query and displayed via error state + } + }, + [email, displayName, password, passwordValidation.isValid, passwordsMatch, setup] + ); + + const handleEmailChange = useCallback((e: ChangeEvent) => { + setEmail(e.target.value); + }, []); + + const handleDisplayNameChange = useCallback((e: ChangeEvent) => { + setDisplayName(e.target.value); + }, []); + + const handlePasswordChange = useCallback((e: ChangeEvent) => { + setPassword(e.target.value); + }, []); + + const handleConfirmPasswordChange = useCallback((e: ChangeEvent) => { + setConfirmPassword(e.target.value); + }, []); + + const errorMessage = error + ? 'data' in error && typeof error.data === 'object' && error.data && 'detail' in error.data + ? String(error.data.detail) + : t('auth.setup.setupFailed') + : null; + + // Show loading spinner while checking setup status or redirecting + if (isLoadingSetup || (setupStatus && !setupStatus.multiuser_enabled)) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ + + + {t('auth.setup.title')} + + + {t('auth.setup.subtitle')} + + + + + + + + {t('auth.setup.email')} + + + + + {t('auth.setup.emailHelper')} + + + + + + + + + {t('auth.setup.displayName')} + + + + + {t('auth.setup.displayNameHelper')} + + + + + 0 && !passwordValidation.isValid}> + + + + {t('auth.setup.password')} + + + + + {password.length > 0 && !passwordValidation.isValid && ( + {passwordValidation.message} + )} + {password.length === 0 && {t('auth.setup.passwordHelper')}} + + + + + 0 && !passwordsMatch}> + + + + {t('auth.setup.confirmPassword')} + + + + + {confirmPassword.length > 0 && !passwordsMatch && ( + {t('auth.setup.passwordsDoNotMatch')} + )} + + + + + + + {errorMessage && ( + + {errorMessage} + + )} + +
+
+
+ ); +}); + +AdministratorSetup.displayName = 'AdministratorSetup'; diff --git a/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx b/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx new file mode 100644 index 0000000000..ddc813163d --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx @@ -0,0 +1,168 @@ +import { + Box, + Button, + Center, + Checkbox, + Flex, + FormControl, + FormErrorMessage, + FormLabel, + Heading, + Input, + Spinner, + Text, + VStack, +} from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { setCredentials } from 'features/auth/store/authSlice'; +import type { ChangeEvent, FormEvent } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useGetSetupStatusQuery, useLoginMutation } from 'services/api/endpoints/auth'; + +export const LoginPage = memo(() => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [rememberMe, setRememberMe] = useState(true); + const [login, { isLoading, error }] = useLoginMutation(); + const dispatch = useAppDispatch(); + const { data: setupStatus, isLoading: isLoadingSetup } = useGetSetupStatusQuery(); + + // Redirect to app if multiuser mode is disabled + useEffect(() => { + if (!isLoadingSetup && setupStatus && !setupStatus.multiuser_enabled) { + navigate('/app', { replace: true }); + } + }, [setupStatus, isLoadingSetup, navigate]); + + // Redirect to setup page if setup is required + useEffect(() => { + if (!isLoadingSetup && setupStatus?.setup_required) { + navigate('/setup', { replace: true }); + } + }, [setupStatus, isLoadingSetup, navigate]); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + try { + const result = await login({ email, password, remember_me: rememberMe }).unwrap(); + // Map the UserDTO from API to our User type + const user = { + user_id: result.user.user_id, + email: result.user.email, + display_name: result.user.display_name || null, + is_admin: result.user.is_admin || false, + is_active: result.user.is_active || true, + }; + dispatch(setCredentials({ token: result.token, user })); + // Force a page reload to ensure all user-specific state is loaded from server + // This is important for multiuser isolation to prevent state leakage + window.location.href = '/app'; + } catch { + // Error is handled by RTK Query and displayed via error state + } + }, + [email, password, rememberMe, login, dispatch] + ); + + const handleEmailChange = useCallback((e: ChangeEvent) => { + setEmail(e.target.value); + }, []); + + const handlePasswordChange = useCallback((e: ChangeEvent) => { + setPassword(e.target.value); + }, []); + + const handleRememberMeChange = useCallback((e: ChangeEvent) => { + setRememberMe(e.target.checked); + }, []); + + const errorMessage = error + ? 'data' in error && typeof error.data === 'object' && error.data && 'detail' in error.data + ? String(error.data.detail) + : t('auth.login.loginFailed') + : null; + + // Show loading spinner while checking setup status or redirecting + if (isLoadingSetup || (setupStatus && !setupStatus.multiuser_enabled)) { + return ( +
+ +
+ ); + } + + // Show loading spinner if setup is required (redirecting to setup) + if (setupStatus?.setup_required) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ + + {t('auth.login.title')} + + + + {t('auth.login.email')} + + + + + {t('auth.login.password')} + + {errorMessage && {errorMessage}} + + + + {t('auth.login.rememberMe')} + + + + + {errorMessage && ( + + {errorMessage} + + )} + +
+
+
+ ); +}); + +LoginPage.displayName = 'LoginPage'; diff --git a/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx new file mode 100644 index 0000000000..a53692b150 --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/components/ProtectedRoute.tsx @@ -0,0 +1,100 @@ +import { Center, Spinner } from '@invoke-ai/ui-library'; +import type { RootState } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { logout, setCredentials } from 'features/auth/store/authSlice'; +import type { PropsWithChildren } from 'react'; +import { memo, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useGetCurrentUserQuery, useGetSetupStatusQuery } from 'services/api/endpoints/auth'; + +interface ProtectedRouteProps { + requireAdmin?: boolean; +} + +export const ProtectedRoute = memo(({ children, requireAdmin = false }: PropsWithChildren) => { + const isAuthenticated = useAppSelector((state: RootState) => state.auth?.isAuthenticated || false); + const token = useAppSelector((state: RootState) => state.auth?.token); + const user = useAppSelector((state: RootState) => state.auth?.user); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + // Check if multiuser mode is enabled + const { data: setupStatus } = useGetSetupStatusQuery(); + const multiuserEnabled = setupStatus?.multiuser_enabled ?? true; // Default to true for safety + + // Only fetch user if we have a token but no user data, and multiuser mode is enabled + const shouldFetchUser = multiuserEnabled && isAuthenticated && token && !user; + const { + data: currentUser, + isLoading: isLoadingUser, + error: userError, + } = useGetCurrentUserQuery(undefined, { + skip: !shouldFetchUser, + }); + + useEffect(() => { + // If we have a token but fetching user failed, token is invalid - logout + if (userError && isAuthenticated) { + dispatch(logout()); + navigate('/login', { replace: true }); + } + }, [userError, isAuthenticated, dispatch, navigate]); + + useEffect(() => { + // If we successfully fetched user data, update auth state + if (currentUser && token && !user) { + const userObj = { + user_id: currentUser.user_id, + email: currentUser.email, + display_name: currentUser.display_name || null, + is_admin: currentUser.is_admin || false, + is_active: currentUser.is_active || true, + }; + dispatch(setCredentials({ token, user: userObj })); + } + }, [currentUser, token, user, dispatch]); + + useEffect(() => { + // If multiuser is disabled, allow access without authentication + if (!multiuserEnabled) { + // Clear any persisted auth state when switching to single-user mode + if (isAuthenticated) { + dispatch(logout()); + } + return; + } + + // In multiuser mode, check authentication + if (!isLoadingUser && !isAuthenticated) { + navigate('/login', { replace: true }); + } else if (!isLoadingUser && isAuthenticated && user && requireAdmin && !user.is_admin) { + navigate('/', { replace: true }); + } + }, [isAuthenticated, isLoadingUser, requireAdmin, user, navigate, multiuserEnabled, dispatch]); + + // In single-user mode, always allow access + if (!multiuserEnabled) { + return <>{children}; + } + + // Show loading while fetching user data + if (isLoadingUser || (isAuthenticated && !user)) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return null; + } + + if (requireAdmin && !user?.is_admin) { + return null; + } + + return <>{children}; +}); + +ProtectedRoute.displayName = 'ProtectedRoute'; diff --git a/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx b/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx new file mode 100644 index 0000000000..970c1d7533 --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx @@ -0,0 +1,71 @@ +import { Badge, Flex, IconButton, Menu, MenuButton, MenuItem, MenuList, Text, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { logout, selectCurrentUser } from 'features/auth/store/authSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSignOutBold, PiUserBold } from 'react-icons/pi'; +import { useNavigate } from 'react-router-dom'; +import { useLogoutMutation } from 'services/api/endpoints/auth'; + +export const UserMenu = memo(() => { + const { t } = useTranslation(); + const user = useAppSelector(selectCurrentUser); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const [logoutMutation] = useLogoutMutation(); + + const handleLogout = useCallback(() => { + // Call backend logout endpoint + logoutMutation() + .unwrap() + .catch(() => { + // Ignore errors - we'll log out locally anyway + }) + .finally(() => { + // Clear local state regardless of backend response + dispatch(logout()); + navigate('/login'); + }); + }, [dispatch, navigate, logoutMutation]); + + if (!user) { + return null; + } + + return ( + + + } + variant="link" + minW={8} + w={8} + h={8} + borderRadius="base" + /> + + + + + {user.display_name || user.email} + + + {user.email} + + {user.is_admin && ( + + {t('auth.admin')} + + )} + + } onClick={handleLogout}> + {t('auth.logout')} + + + + ); +}); + +UserMenu.displayName = 'UserMenu'; diff --git a/invokeai/frontend/web/src/features/auth/store/authSlice.ts b/invokeai/frontend/web/src/features/auth/store/authSlice.ts new file mode 100644 index 0000000000..6ac65ef03c --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/store/authSlice.ts @@ -0,0 +1,83 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; +import type { SliceConfig } from 'app/store/types'; +import { z } from 'zod'; + +const zUser = z.object({ + user_id: z.string(), + email: z.string(), + display_name: z.string().nullable(), + is_admin: z.boolean(), + is_active: z.boolean(), +}); + +const zAuthState = z.object({ + isAuthenticated: z.boolean(), + token: z.string().nullable(), + user: zUser.nullable(), + isLoading: z.boolean(), +}); + +type User = z.infer; +type AuthState = z.infer; + +// Helper to safely access localStorage (not available in test environment) +const getStoredAuthToken = (): string | null => { + if (typeof window !== 'undefined' && window.localStorage) { + return localStorage.getItem('auth_token'); + } + return null; +}; + +const initialState: AuthState = { + isAuthenticated: !!getStoredAuthToken(), + token: getStoredAuthToken(), + user: null, + isLoading: false, +}; + +const getInitialAuthState = (): AuthState => initialState; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setCredentials: (state, action: PayloadAction<{ token: string; user: User }>) => { + state.token = action.payload.token; + state.user = action.payload.user; + state.isAuthenticated = true; + if (typeof window !== 'undefined' && window.localStorage) { + localStorage.setItem('auth_token', action.payload.token); + } + }, + logout: (state) => { + state.token = null; + state.user = null; + state.isAuthenticated = false; + if (typeof window !== 'undefined' && window.localStorage) { + localStorage.removeItem('auth_token'); + } + }, + setLoading: (state, action: PayloadAction) => { + state.isLoading = action.payload; + }, + }, +}); + +export const { setCredentials, logout, setLoading } = authSlice.actions; + +export const authSliceConfig: SliceConfig = { + slice: authSlice, + schema: zAuthState, + getInitialState: getInitialAuthState, + persistConfig: { + migrate: () => getInitialAuthState(), + // Don't persist auth state - token is stored in localStorage + persistDenylist: ['isAuthenticated', 'token', 'user', 'isLoading'], + }, +}; + +export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated; +export const selectCurrentUser = (state: { auth: AuthState }) => state.auth.user; +export const selectAuthToken = (state: { auth: AuthState }) => state.auth.token; +export const selectIsAuthLoading = (state: { auth: AuthState }) => state.auth.isLoading; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 3cc2606172..8dcd93cc5d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -6,6 +6,7 @@ import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { isPlainObject } from 'es-toolkit'; import { clamp } from 'es-toolkit/compat'; +import { logout } from 'features/auth/store/authSlice'; import type { AspectRatioID, InfillMethod, ParamsState, RgbaColor } from 'features/controlLayers/store/types'; import { ASPECT_RATIO_MAP, @@ -428,6 +429,12 @@ const slice = createSlice({ }, paramsReset: (state) => resetState(state), }, + extraReducers(builder) { + // Reset params state on logout to prevent user data leakage when switching users + builder.addCase(logout, () => { + return getInitialParamsState(); + }); + }, }); const applyClipSkip = (state: { clipSkip: number }, model: ParameterModel | null, clipSkip: number) => { diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 1ddc4b0db3..4d821f819c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -2,6 +2,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, Flex, Icon, Image, Text, Tooltip } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import type { AddImageToBoardDndTargetData } from 'features/dnd/dnd'; import { addImageToBoardDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; @@ -36,6 +37,7 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { const autoAddBoardId = useAppSelector(selectAutoAddBoardId); const autoAssignBoardOnClick = useAppSelector(selectAutoAssignBoardOnClick); const selectedBoardId = useAppSelector(selectSelectedBoardId); + const currentUser = useAppSelector(selectCurrentUser); const onClick = useCallback(() => { if (selectedBoardId !== board.board_id) { dispatch(boardIdSelected({ boardId: board.board_id })); @@ -58,6 +60,8 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { [board] ); + const showOwner = currentUser?.is_admin && board.owner_username; + return ( @@ -85,8 +89,13 @@ const GalleryBoard = ({ board, isSelected }: GalleryBoardProps) => { h="full" > - + + {showOwner && ( + + {board.owner_username} + + )} {autoAddBoardId === board.board_id && } {board.archived && } diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index d66feefa2c..9d4d2bfd75 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -3,6 +3,7 @@ import { createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject, uniq } from 'es-toolkit'; +import { logout } from 'features/auth/store/authSlice'; import type { BoardRecordOrderBy } from 'services/api/types'; import { assert } from 'tsafe'; @@ -142,6 +143,14 @@ const slice = createSlice({ state.boardsListOrderDir = action.payload; }, }, + extraReducers(builder) { + // Clear board-related state on logout to prevent stale data when switching users + builder.addCase(logout, (state) => { + state.selectedBoardId = 'none'; + state.autoAddBoardId = 'none'; + state.boardSearchText = ''; + }); + }, }); export const { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useIsModelManagerEnabled.ts b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useIsModelManagerEnabled.ts new file mode 100644 index 0000000000..81b00eb77e --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useIsModelManagerEnabled.ts @@ -0,0 +1,29 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; +import { useMemo } from 'react'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; + +/** + * Hook to determine if model manager features should be enabled for the current user. + * + * Returns true if: + * - Multiuser mode is disabled (single-user mode = always admin) + * - Multiuser mode is enabled AND user is an admin + * + * Returns false if: + * - Multiuser mode is enabled AND user is not an admin + */ +export const useIsModelManagerEnabled = (): boolean => { + const user = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); + + return useMemo(() => { + // If multiuser is disabled, treat as admin (single-user mode) + if (setupStatus && !setupStatus.multiuser_enabled) { + return true; + } + + // If multiuser is enabled, check if user is admin + return user?.is_admin ?? false; + }, [setupStatus, user]); +}; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx index f7e91af62f..d1774f9ded 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/hooks/useStarterModelsToast.tsx @@ -1,4 +1,6 @@ import { Button, Text, useToast } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { 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'; @@ -12,8 +14,14 @@ export const useStarterModelsToast = () => { const [didToast, setDidToast] = useState(false); const [mainModels, { data }] = useMainModels(); const toast = useToast(); + const isAuthenticated = useAppSelector(selectIsAuthenticated); useEffect(() => { + // Only show the toast if the user is authenticated + if (!isAuthenticated) { + return; + } + if (toast.isActive(TOAST_ID)) { if (mainModels.length === 0) { return; @@ -32,7 +40,7 @@ export const useStarterModelsToast = () => { onCloseComplete: () => setDidToast(true), }); } - }, [data, didToast, mainModels.length, t, toast]); + }, [data, didToast, isAuthenticated, mainModels.length, t, toast]); }; const ToastDescription = () => { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx index a6c462ddf5..f6e1a18f6f 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManager.tsx @@ -1,6 +1,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, Flex, Heading } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { selectSelectedModelKey, setSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -23,6 +24,7 @@ const modelManagerSx: SystemStyleObject = { export const ModelManager = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); + const canManageModels = useIsModelManagerEnabled(); const handleClickAddModel = useCallback(() => { dispatch(setSelectedModelKey(null)); }, [dispatch]); @@ -36,7 +38,7 @@ export const ModelManager = memo(() => { - {!!selectedModelKey && ( + {!!selectedModelKey && canManageModels && ( diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx index 1e6281f1c1..57e970b48d 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListBulkActions.tsx @@ -1,6 +1,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Button, Checkbox, Flex, Menu, MenuButton, MenuItem, MenuList, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import type { FilterableModelType } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { modelSelectionChanged, @@ -32,6 +33,7 @@ type ModelListBulkActionsProps = { export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => { const dispatch = useAppDispatch(); + const canManageModels = useIsModelManagerEnabled(); const filteredModelType = useAppSelector(selectFilteredModelType); const selectedModelKeys = useAppSelector(selectSelectedModelKeys); const searchTerm = useAppSelector(selectSearchTerm); @@ -110,23 +112,25 @@ export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => {selectionCount} {t('common.selected')} - - } - flexShrink={0} - variant="outline" - > - {t('modelManager.actions')} - - - } onClick={handleBulkDelete} color="error.300"> - {t('modelManager.deleteModels', { count: selectionCount })} - - - + {canManageModels && ( + + } + flexShrink={0} + variant="outline" + > + {t('modelManager.actions')} + + + } onClick={handleBulkDelete} color="error.300"> + {t('modelManager.deleteModels', { count: selectionCount })} + + + + )} ); diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx index b1f1083966..67cde939ba 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPane.tsx @@ -1,8 +1,10 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { Box } from '@invoke-ai/ui-library'; +import { Box, Center, Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { selectSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; import { InstallModels } from './InstallModels'; import { Model } from './ModelPanel/Model'; @@ -23,7 +25,37 @@ const modelPaneSx: SystemStyleObject = { export const ModelPane = memo(() => { const selectedModelKey = useAppSelector(selectSelectedModelKey); - return {selectedModelKey ? : }; + const canManageModels = useIsModelManagerEnabled(); + const { t } = useTranslation(); + + // Show model details if a model is selected + if (selectedModelKey) { + return ( + + + + ); + } + + // Show install panel for users with model management permissions, empty state for others + if (canManageModels) { + return ( + + + + ); + } + + // Empty state for users without model management permissions + return ( + +
+ + {t('modelManager.selectModelToView')} + +
+
+ ); }); ModelPane.displayName = 'ModelPane'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx index 0bcd5b2716..92d509011c 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx @@ -1,5 +1,6 @@ import { Button, Flex, Heading, SimpleGrid } from '@invoke-ai/ui-library'; import { useControlAdapterModelDefaultSettings } from 'features/modelManagerV2/hooks/useControlAdapterModelDefaultSettings'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { DefaultPreprocessor } from 'features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/DefaultPreprocessor'; import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; import { toast } from 'features/toast/toast'; @@ -21,6 +22,7 @@ type Props = { export const ControlAdapterModelDefaultSettings = memo(({ modelConfig }: Props) => { const { t } = useTranslation(); + const canManageModels = useIsModelManagerEnabled(); const defaultSettingsDefaults = useControlAdapterModelDefaultSettings(modelConfig); @@ -66,16 +68,18 @@ export const ControlAdapterModelDefaultSettings = memo(({ modelConfig }: Props) <> {t('modelManager.defaultSettings')} - + {canManageModels && ( + + )} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx index a012460161..d2f55540af 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx @@ -1,4 +1,5 @@ import { Button, Flex, Heading, SimpleGrid } from '@invoke-ai/ui-library'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { useLoRAModelDefaultSettings } from 'features/modelManagerV2/hooks/useLoRAModelDefaultSettings'; import { DefaultWeight } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight'; import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; @@ -21,6 +22,7 @@ type Props = { export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { const { t } = useTranslation(); + const canManageModels = useIsModelManagerEnabled(); const defaultSettingsDefaults = useLoRAModelDefaultSettings(modelConfig); @@ -66,16 +68,18 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { <> {t('modelManager.defaultSettings')} - + {canManageModels && ( + + )} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx index 4fa0f29beb..8497eee02e 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx @@ -1,5 +1,6 @@ import { Button, Flex, Heading, SimpleGrid } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { useMainModelDefaultSettings } from 'features/modelManagerV2/hooks/useMainModelDefaultSettings'; import { selectSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice'; import { DefaultHeight } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/DefaultHeight'; @@ -46,6 +47,7 @@ type Props = { export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => { const selectedModelKey = useAppSelector(selectSelectedModelKey); + const canManageModels = useIsModelManagerEnabled(); const { t } = useTranslation(); const isFluxFamily = useMemo(() => { @@ -111,16 +113,18 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => { <> {t('modelManager.defaultSettings')} - + {canManageModels && ( + + )} diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelHeader.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelHeader.tsx index a30f96b7fc..1c1a05dbd0 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelHeader.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelHeader.tsx @@ -1,4 +1,5 @@ import { Flex, Heading, Spacer, Text } from '@invoke-ai/ui-library'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import ModelImageUpload from 'features/modelManagerV2/subpanels/ModelPanel/Fields/ModelImageUpload'; import type { PropsWithChildren } from 'react'; import { memo } from 'react'; @@ -11,9 +12,11 @@ type Props = PropsWithChildren<{ export const ModelHeader = memo(({ modelConfig, children }: Props) => { const { t } = useTranslation(); + const canManageModels = useIsModelManagerEnabled(); + return ( - + {canManageModels && } diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx index c2d20ccbbd..6e114bb252 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -1,4 +1,5 @@ import { Box, Divider, Flex, SimpleGrid } from '@invoke-ai/ui-library'; +import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { ControlAdapterModelDefaultSettings } from 'features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings'; import { EncoderModelSettings } from 'features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings'; import { LoRAModelDefaultSettings } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings'; @@ -52,6 +53,7 @@ type Props = { export const ModelView = memo(({ modelConfig }: Props) => { const { t } = useTranslation(); + const canManageModels = useIsModelManagerEnabled(); // Only allow path updates for external models (not Invoke-controlled) const canUpdatePath = useMemo(() => isExternalModel(modelConfig.path), [modelConfig.path]); @@ -81,13 +83,13 @@ export const ModelView = memo(({ modelConfig }: Props) => { return ( - {canUpdatePath && } - - {modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && ( + {canManageModels && canUpdatePath && } + {canManageModels && } + {canManageModels && modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && ( )} - - + {canManageModels && } + {canManageModels && } diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx index a22bbfcc04..219418f36c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/UseCacheCheckbox.tsx @@ -1,15 +1,29 @@ import { Checkbox, FormControl, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { useUseCache } from 'features/nodes/hooks/useUseCache'; import { nodeUseCacheChanged } from 'features/nodes/store/nodesSlice'; import { NO_FIT_ON_DOUBLE_CLICK_CLASS, NO_PAN_CLASS } from 'features/nodes/types/constants'; import type { ChangeEvent } from 'react'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => { const dispatch = useAppDispatch(); const useCache = useUseCache(); + const currentUser = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); + + const isVisible = useMemo(() => { + // In single-user mode (multiuser disabled), always show the checkbox + if (setupStatus && !setupStatus.multiuser_enabled) { + return true; + } + // In multiuser mode, only show the checkbox to admin users + return currentUser?.is_admin ?? false; + }, [setupStatus, currentUser]); + const handleChange = useCallback( (e: ChangeEvent) => { dispatch( @@ -22,6 +36,11 @@ const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => { [dispatch, nodeId] ); const { t } = useTranslation(); + + if (!isVisible) { + return null; + } + return ( {t('invocationCache.useCache')} diff --git a/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx b/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx index 5093f89d57..3417488b09 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueCountBadge.tsx @@ -1,20 +1,66 @@ import { Badge, Portal } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectIsAuthenticated } from 'features/auth/store/authSlice'; import type { RefObject } from 'react'; -import { memo, useEffect, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; +import type { components } from 'services/api/schema'; type Props = { targetRef: RefObject; }; +type SessionQueueStatus = components['schemas']['SessionQueueStatus']; + +/** + * Determines if user-specific queue counts are available. + */ +const hasUserCounts = (queueData: SessionQueueStatus): boolean => { + return ( + queueData.user_pending !== undefined && + queueData.user_pending !== null && + queueData.user_in_progress !== undefined && + queueData.user_in_progress !== null + ); +}; + +/** + * Calculates the appropriate badge text based on queue status and authentication state. + * Returns null if badge should be hidden. + */ +const getBadgeText = (queueData: SessionQueueStatus | undefined, isAuthenticated: boolean): string | null => { + if (!queueData) { + return null; + } + + const totalPending = queueData.pending + queueData.in_progress; + + // Hide badge if there are no pending jobs + if (totalPending === 0) { + return null; + } + + // In multiuser mode (authenticated user), show "X/Y" format where X is user's jobs and Y is total jobs + if (isAuthenticated && hasUserCounts(queueData)) { + const userPending = queueData.user_pending! + queueData.user_in_progress!; + return `${userPending}/${totalPending}`; + } + + // In single-user mode or when user counts aren't available, show total count only + return totalPending.toString(); +}; + export const QueueCountBadge = memo(({ targetRef }: Props) => { const [badgePos, setBadgePos] = useState<{ x: string; y: string } | null>(null); - const { queueSize } = useGetQueueStatusQuery(undefined, { + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const { queueData } = useGetQueueStatusQuery(undefined, { selectFromResult: (res) => ({ - queueSize: res.data ? res.data.queue.pending + res.data.queue.in_progress : 0, + queueData: res.data?.queue, }), }); + const badgeText = useMemo(() => getBadgeText(queueData, isAuthenticated), [queueData, isAuthenticated]); + useEffect(() => { if (!targetRef.current) { return; @@ -57,7 +103,7 @@ export const QueueCountBadge = memo(({ targetRef }: Props) => { }; }, [targetRef]); - if (queueSize === 0) { + if (!badgeText) { return null; } if (!badgePos) { @@ -75,7 +121,7 @@ export const QueueCountBadge = memo(({ targetRef }: Props) => { shadow="dark-lg" userSelect="none" > - {queueSize} + {badgeText} ); diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx index e0109a6b05..e1c5f4ec97 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueItemComponent.tsx @@ -1,5 +1,7 @@ import type { ChakraProps, CollapseProps, FlexProps } from '@invoke-ai/ui-library'; import { ButtonGroup, Collapse, Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import QueueStatusBadge from 'features/queue/components/common/QueueStatusBadge'; import { useDestinationText } from 'features/queue/components/QueueList/useDestinationText'; import { useOriginText } from 'features/queue/components/QueueList/useOriginText'; @@ -12,7 +14,7 @@ import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiXBold } from 'react-icons/pi'; import type { S } from 'services/api/types'; -import { COLUMN_WIDTHS } from './constants'; +import { COLUMN_WIDTHS, SYSTEM_USER_ID } from './constants'; import QueueItemDetail from './QueueItemDetail'; const selectedStyles = { bg: 'base.700' }; @@ -30,7 +32,44 @@ const sx: ChakraProps['sx'] = { const QueueItemComponent = ({ index, item }: InnerItemProps) => { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); - const handleToggle = useCallback(() => setIsOpen((s) => !s), [setIsOpen]); + const currentUser = useAppSelector(selectCurrentUser); + + // Check if current user can manage this queue item + const canManageItem = useMemo(() => { + if (!currentUser) { + return false; + } + // Admin users can manage all items + if (currentUser.is_admin) { + return true; + } + // Non-admin users can only manage their own items + return item.user_id === currentUser.user_id; + }, [currentUser, item.user_id]); + + // Check if the current user can view this queue item's details + const canViewDetails = useMemo(() => { + // Admins can view all items + if (currentUser?.is_admin) { + return true; + } + // Users can view their own items + if (currentUser?.user_id === item.user_id) { + return true; + } + // System items can be viewed by anyone + if (item.user_id === SYSTEM_USER_ID) { + return true; + } + return false; + }, [currentUser, item.user_id]); + + const handleToggle = useCallback(() => { + if (canViewDetails) { + setIsOpen((s) => !s); + } + }, [canViewDetails]); + const cancelQueueItem = useCancelQueueItem(); const onClickCancelQueueItem = useCallback( (e: MouseEvent) => { @@ -61,6 +100,17 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { const originText = useOriginText(item.origin); const destinationText = useDestinationText(item.destination); + // Display user name - prefer display_name, fallback to email, then user_id + const userText = useMemo(() => { + if (item.user_display_name) { + return item.user_display_name; + } + if (item.user_email) { + return item.user_email; + } + return item.user_id || SYSTEM_USER_ID; + }, [item.user_display_name, item.user_email, item.user_id]); + return ( { sx={sx} data-testid="queue-item" > - + {index + 1} @@ -95,6 +154,11 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { {item.batch_id} + + + {userText} + + {item.field_values && ( @@ -110,6 +174,11 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { ))} )} + {!item.field_values && !currentUser?.is_admin && item.user_id !== currentUser?.user_id && ( + + {t('queue.fieldValuesHidden')} + + )} @@ -117,7 +186,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { {!isFailed && ( } @@ -126,6 +195,7 @@ const QueueItemComponent = ({ index, item }: InnerItemProps) => { {isFailed && ( } diff --git a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx index cdfd47f211..4cd3397d21 100644 --- a/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx +++ b/invokeai/frontend/web/src/features/queue/components/QueueList/QueueListHeader.tsx @@ -42,6 +42,7 @@ const QueueListHeader = () => { alignItems="center" /> + { title: t('queue.cancelSucceeded'), status: 'success', }); - } catch { + } catch (error) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts index 9821328871..797a940507 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts @@ -1,11 +1,30 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { useCurrentQueueItemId } from 'features/queue/hooks/useCurrentQueueItemId'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useGetCurrentQueueItemQuery } from 'services/api/endpoints/queue'; import { useCancelQueueItem } from './useCancelQueueItem'; export const useCancelCurrentQueueItem = () => { const currentQueueItemId = useCurrentQueueItemId(); + const { data: currentQueueItem } = useGetCurrentQueueItemQuery(); + const currentUser = useAppSelector(selectCurrentUser); const cancelQueueItem = useCancelQueueItem(); + + // Check if current user can cancel the current item + const canCancelCurrentItem = useMemo(() => { + if (!currentUser || !currentQueueItem) { + return false; + } + // Admin users can cancel all items + if (currentUser.is_admin) { + return true; + } + // Non-admin users can only cancel their own items + return currentQueueItem.user_id === currentUser.user_id; + }, [currentUser, currentQueueItem]); + const trigger = useCallback( (options?: { withToast?: boolean }) => { if (currentQueueItemId === null) { @@ -19,6 +38,6 @@ export const useCancelCurrentQueueItem = () => { return { trigger, isLoading: cancelQueueItem.isLoading, - isDisabled: cancelQueueItem.isDisabled || currentQueueItemId === null, + isDisabled: cancelQueueItem.isDisabled || currentQueueItemId === null || !canCancelCurrentItem, }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts index c122241cbd..b85fe8d373 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItem.ts @@ -25,11 +25,13 @@ export const useCancelQueueItem = () => { status: 'success', }); } - } catch { + } catch (error) { if (withToast) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts index 14864e0e3f..df0eabcb52 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelQueueItemsByDestination.ts @@ -26,11 +26,13 @@ export const useCancelQueueItemsByDestination = () => { status: 'success', }); } - } catch { + } catch (error) { if (withToast) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts index a81f7254be..bd6ea2cc02 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useClearQueue.ts @@ -25,10 +25,12 @@ export const useClearQueue = () => { title: t('queue.clearSucceeded'), status: 'success', }); - } catch { + } catch (error) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CLEAR_FAILED', - title: t('queue.clearFailed'), + title: isAccessDenied ? t('queue.clearFailedAccessDenied') : t('queue.clearFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts index 1f34a76d24..b96c391470 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteAllExceptCurrentQueueItem.ts @@ -25,10 +25,12 @@ export const useDeleteAllExceptCurrentQueueItem = () => { title: t('queue.cancelSucceeded'), status: 'success', }); - } catch { + } catch (error) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts index af91196ddf..699a81ac74 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useDeleteQueueItem.ts @@ -25,11 +25,13 @@ export const useDeleteQueueItem = () => { status: 'success', }); } - } catch { + } catch (error) { if (withToast) { + // Check if this is a 403 access denied error + const isAccessDenied = error instanceof Object && 'status' in error && error.status === 403; toast({ id: 'QUEUE_CANCEL_FAILED', - title: t('queue.cancelFailed'), + title: isAccessDenied ? t('queue.cancelFailedAccessDenied') : t('queue.cancelFailed'), status: 'error', }); } diff --git a/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts b/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts index 9e82576a4f..4b1ce0839a 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/usePauseProcessor.ts @@ -1,7 +1,10 @@ import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import { useGetQueueStatusQuery, usePauseProcessorMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,10 +12,20 @@ export const usePauseProcessor = () => { const { t } = useTranslation(); const isConnected = useStore($isConnected); const { data: queueStatus } = useGetQueueStatusQuery(); + const currentUser = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); const [_trigger, { isLoading }] = usePauseProcessorMutation({ fixedCacheKey: 'pauseProcessor', }); + // In single-user mode, treat as admin. In multiuser mode, check is_admin flag. + const isAdmin = useMemo(() => { + if (setupStatus && !setupStatus.multiuser_enabled) { + return true; + } + return currentUser?.is_admin ?? false; + }, [setupStatus, currentUser]); + const trigger = useCallback(async () => { try { await _trigger().unwrap(); @@ -30,5 +43,5 @@ export const usePauseProcessor = () => { } }, [_trigger, t]); - return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.processor.is_started }; + return { trigger, isLoading, isDisabled: !isConnected || !queueStatus?.processor.is_started || !isAdmin }; }; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts b/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts index 901bac39f8..3248097cec 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useResumeProcessor.ts @@ -1,7 +1,10 @@ import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import { useGetQueueStatusQuery, useResumeProcessorMutation } from 'services/api/endpoints/queue'; import { $isConnected } from 'services/events/stores'; @@ -9,10 +12,20 @@ export const useResumeProcessor = () => { const isConnected = useStore($isConnected); const { data: queueStatus } = useGetQueueStatusQuery(); const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); const [_trigger, { isLoading }] = useResumeProcessorMutation({ fixedCacheKey: 'resumeProcessor', }); + // In single-user mode, treat as admin. In multiuser mode, check is_admin flag. + const isAdmin = useMemo(() => { + if (setupStatus && !setupStatus.multiuser_enabled) { + return true; + } + return currentUser?.is_admin ?? false; + }, [setupStatus, currentUser]); + const trigger = useCallback(async () => { try { await _trigger().unwrap(); @@ -30,5 +43,5 @@ export const useResumeProcessor = () => { } }, [_trigger, t]); - return { trigger, isLoading, isDisabled: !isConnected || queueStatus?.processor.is_started }; + return { trigger, isLoading, isDisabled: !isConnected || queueStatus?.processor.is_started || !isAdmin }; }; diff --git a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx index 4d2696c2e3..be3fa3e689 100644 --- a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx +++ b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx @@ -1,4 +1,5 @@ import { Divider, Flex, Spacer } from '@invoke-ai/ui-library'; +import { UserMenu } from 'features/auth/components/UserMenu'; import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent'; import SettingsMenu from 'features/system/components/SettingsModal/SettingsMenu'; import StatusIndicator from 'features/system/components/StatusIndicator'; @@ -39,6 +40,7 @@ export const VerticalNavBar = memo(() => { + diff --git a/invokeai/frontend/web/src/i18n.ts b/invokeai/frontend/web/src/i18n.ts index 89c855bcd0..adf53c0fd9 100644 --- a/invokeai/frontend/web/src/i18n.ts +++ b/invokeai/frontend/web/src/i18n.ts @@ -32,7 +32,7 @@ if (import.meta.env.MODE === 'package') { fallbackLng: 'en', debug: false, backend: { - loadPath: `${window.location.href.replace(/\/$/, '')}/locales/{{lng}}.json`, + loadPath: `${window.location.origin}/locales/{{lng}}.json`, }, interpolation: { escapeValue: false, diff --git a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts index f72d6ad81e..8fe85125e6 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts @@ -84,7 +84,7 @@ export const appInfoApi = api.injectEndpoints({ invalidatesTags: ['InvocationCacheStatus'], }), getOpenAPISchema: build.query({ - query: () => `${window.location.href.replace(/\/$/, '')}/openapi.json`, + query: () => `${window.location.origin}/openapi.json`, providesTags: ['Schema'], }), }), diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts new file mode 100644 index 0000000000..ba81c08136 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts @@ -0,0 +1,74 @@ +import { api } from 'services/api'; +import type { components } from 'services/api/schema'; + +type LoginRequest = { + email: string; + password: string; + remember_me?: boolean; +}; + +type LoginResponse = { + token: string; + user: components['schemas']['UserDTO']; + expires_in: number; +}; + +type SetupRequest = { + email: string; + display_name: string; + password: string; +}; + +type SetupResponse = { + success: boolean; + user: components['schemas']['UserDTO']; +}; + +type MeResponse = components['schemas']['UserDTO']; + +type LogoutResponse = { + success: boolean; +}; + +type SetupStatusResponse = { + setup_required: boolean; + multiuser_enabled: boolean; +}; + +export const authApi = api.injectEndpoints({ + endpoints: (build) => ({ + login: build.mutation({ + query: (credentials) => ({ + url: 'api/v1/auth/login', + method: 'POST', + body: credentials, + }), + // Invalidate boards and images cache on successful login to refresh data for new user + invalidatesTags: ['Board', 'Image', 'ImageList', 'ImageNameList', 'ImageCollection', 'ImageMetadata'], + }), + logout: build.mutation({ + query: () => ({ + url: 'api/v1/auth/logout', + method: 'POST', + }), + // Invalidate boards and images cache on logout to clear stale data + invalidatesTags: ['Board', 'Image', 'ImageList', 'ImageNameList', 'ImageCollection', 'ImageMetadata'], + }), + getCurrentUser: build.query({ + query: () => 'api/v1/auth/me', + }), + setup: build.mutation({ + query: (setupData) => ({ + url: 'api/v1/auth/setup', + method: 'POST', + body: setupData, + }), + }), + getSetupStatus: build.query({ + query: () => 'api/v1/auth/status', + }), + }), +}); + +export const { useLoginMutation, useLogoutMutation, useGetCurrentUserQuery, useSetupMutation, useGetSetupStatusQuery } = + authApi; diff --git a/invokeai/frontend/web/src/services/api/endpoints/queue.ts b/invokeai/frontend/web/src/services/api/endpoints/queue.ts index c246bc30be..e2788406c1 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/queue.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/queue.ts @@ -278,9 +278,12 @@ export const queueApi = api.injectEndpoints({ return []; } return [ + 'SessionQueueStatus', + 'BatchStatus', 'CurrentSessionQueueItem', 'NextSessionQueueItem', 'QueueCountsByDestination', + 'SessionQueueItemIdList', { type: 'SessionQueueItem', id: LIST_TAG }, { type: 'SessionQueueItem', id: LIST_ALL_TAG }, ...item_ids.map((id) => ({ type: 'SessionQueueItem', id }) satisfies ApiTagDescription), diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 795d9539bb..5b75d724e2 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -64,7 +64,7 @@ export const LIST_TAG = 'LIST'; export const LIST_ALL_TAG = 'LIST_ALL'; export const getBaseUrl = (): string => { - return window.location.href.replace(/\/$/, ''); + return window.location.origin; }; const dynamicBaseQuery: BaseQueryFn = (args, api, extraOptions) => { @@ -74,6 +74,20 @@ const dynamicBaseQuery: BaseQueryFn { + // Add auth token to all requests except setup and login + const token = localStorage.getItem('auth_token'); + const isAuthEndpoint = + (args instanceof Object && + typeof args.url === 'string' && + (args.url.includes('/auth/login') || args.url.includes('/auth/setup'))) || + (typeof args === 'string' && (args.includes('/auth/login') || args.includes('/auth/setup'))); + + if (token && !isAuthEndpoint) { + headers.set('Authorization', `Bearer ${token}`); + } + return headers; + }, }; // When fetching the openapi.json, we need to remove circular references from the JSON. diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 8b6cb454db..b605413787 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1,4 +1,152 @@ export type paths = { + "/api/v1/auth/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Setup Status + * @description Check if initial administrator setup is required. + * + * Returns: + * SetupStatusResponse indicating whether setup is needed and multiuser mode status + */ + get: operations["get_setup_status_api_v1_auth_status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Login + * @description Authenticate user and return access token. + * + * Args: + * request: Login credentials (email and password) + * + * Returns: + * LoginResponse containing JWT token and user information + * + * Raises: + * HTTPException: 401 if credentials are invalid or user is inactive + * HTTPException: 403 if multiuser mode is disabled + */ + post: operations["login_api_v1_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Logout + * @description Logout current user. + * + * Currently a no-op since we use stateless JWT tokens. For token invalidation in + * future implementations, consider: + * - Token blacklist: Store invalidated tokens in Redis/database with expiration + * - Token versioning: Add version field to user record, increment on logout + * - Short-lived tokens: Use refresh token pattern with token rotation + * - Session storage: Track active sessions server-side for revocation + * + * Args: + * current_user: The authenticated user (validates token) + * + * Returns: + * LogoutResponse indicating success + */ + post: operations["logout_api_v1_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Current User Info + * @description Get current authenticated user's information. + * + * Args: + * current_user: The authenticated user's token data + * + * Returns: + * UserDTO containing user information + * + * Raises: + * HTTPException: 404 if user is not found (should not happen normally) + */ + get: operations["get_current_user_info_api_v1_auth_me_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/setup": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Setup Admin + * @description Set up initial administrator account. + * + * This endpoint can only be called once, when no admin user exists. It creates + * the first admin user for the system. + * + * Args: + * request: Admin account details (email, display_name, password) + * + * Returns: + * SetupResponse containing the created admin user + * + * Raises: + * HTTPException: 400 if admin already exists or password is weak + * HTTPException: 403 if multiuser mode is disabled + */ + post: operations["setup_admin_api_v1_auth_setup_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/utilities/dynamicprompts": { parameters: { query?: never; @@ -643,7 +791,7 @@ export type paths = { put?: never; /** * Upload Image - * @description Uploads an image + * @description Uploads an image for the current user */ post: operations["upload_image"]; delete?: never; @@ -661,7 +809,7 @@ export type paths = { }; /** * List Image Dtos - * @description Gets a list of image DTOs + * @description Gets a list of image DTOs for the current user */ get: operations["list_image_dtos"]; put?: never; @@ -986,13 +1134,13 @@ export type paths = { }; /** * List Boards - * @description Gets a list of boards + * @description Gets a list of boards for the current user, including shared boards. Admin users see all boards. */ get: operations["list_boards"]; put?: never; /** * Create Board - * @description Creates a board + * @description Creates a board for the current user */ post: operations["create_board"]; delete?: never; @@ -1010,21 +1158,21 @@ export type paths = { }; /** * Get Board - * @description Gets a board + * @description Gets a board (user must have access to it) */ get: operations["get_board"]; put?: never; post?: never; /** * Delete Board - * @description Deletes a board + * @description Deletes a board (user must have access to it) */ delete: operations["delete_board"]; options?: never; head?: never; /** * Update Board - * @description Updates a board + * @description Updates a board (user must have access to it) */ patch: operations["update_board"]; trace?: never; @@ -1360,7 +1508,7 @@ export type paths = { put?: never; /** * Enqueue Batch - * @description Processes a batch and enqueues the output graphs for execution. + * @description Processes a batch and enqueues the output graphs for execution for the current user. */ post: operations["enqueue_batch"]; delete?: never; @@ -1439,7 +1587,7 @@ export type paths = { get?: never; /** * Resume - * @description Resumes session processor + * @description Resumes session processor. Admin only. */ put: operations["resume"]; post?: never; @@ -1459,7 +1607,7 @@ export type paths = { get?: never; /** * Pause - * @description Pauses session processor + * @description Pauses session processor. Admin only. */ put: operations["pause"]; post?: never; @@ -1479,7 +1627,7 @@ export type paths = { get?: never; /** * Cancel All Except Current - * @description Immediately cancels all queue items except in-processing items + * @description Immediately cancels all queue items except in-processing items. Non-admin users can only cancel their own items. */ put: operations["cancel_all_except_current"]; post?: never; @@ -1499,7 +1647,7 @@ export type paths = { get?: never; /** * Delete All Except Current - * @description Immediately deletes all queue items except in-processing items + * @description Immediately deletes all queue items except in-processing items. Non-admin users can only delete their own items. */ put: operations["delete_all_except_current"]; post?: never; @@ -1519,7 +1667,7 @@ export type paths = { get?: never; /** * Cancel By Batch Ids - * @description Immediately cancels all queue items from the given batch ids + * @description Immediately cancels all queue items from the given batch ids. Non-admin users can only cancel their own items. */ put: operations["cancel_by_batch_ids"]; post?: never; @@ -1539,7 +1687,7 @@ export type paths = { get?: never; /** * Cancel By Destination - * @description Immediately cancels all queue items with the given origin + * @description Immediately cancels all queue items with the given destination. Non-admin users can only cancel their own items. */ put: operations["cancel_by_destination"]; post?: never; @@ -1559,7 +1707,7 @@ export type paths = { get?: never; /** * Retry Items By Id - * @description Immediately cancels all queue items with the given origin + * @description Retries the given queue items. Users can only retry their own items unless they are an admin. */ put: operations["retry_items_by_id"]; post?: never; @@ -1579,7 +1727,7 @@ export type paths = { get?: never; /** * Clear - * @description Clears the queue entirely, immediately canceling the currently-executing session + * @description Clears the queue entirely. Admin users clear all items; non-admin users only clear their own items. If there's a currently-executing item, users can only cancel it if they own it or are an admin. */ put: operations["clear"]; post?: never; @@ -1599,7 +1747,7 @@ export type paths = { get?: never; /** * Prune - * @description Prunes all completed or errored queue items + * @description Prunes all completed or errored queue items. Non-admin users can only prune their own items. */ put: operations["prune"]; post?: never; @@ -1705,7 +1853,7 @@ export type paths = { post?: never; /** * Delete Queue Item - * @description Deletes a queue item + * @description Deletes a queue item. Users can only delete their own items unless they are an admin. */ delete: operations["delete_queue_item"]; options?: never; @@ -1723,7 +1871,7 @@ export type paths = { get?: never; /** * Cancel Queue Item - * @description Deletes a queue item + * @description Cancels a queue item. Users can only cancel their own items unless they are an admin. */ put: operations["cancel_queue_item"]; post?: never; @@ -1765,7 +1913,7 @@ export type paths = { post?: never; /** * Delete By Destination - * @description Deletes all items with the given destination + * @description Deletes all items with the given destination. Non-admin users can only delete their own items. */ delete: operations["delete_by_destination"]; options?: never; @@ -2048,7 +2196,7 @@ export type paths = { }; /** * Get Client State By Key - * @description Gets the client state + * @description Gets the client state for the current user (or system user if not authenticated) */ get: operations["get_client_state_by_key"]; put?: never; @@ -2070,7 +2218,7 @@ export type paths = { put?: never; /** * Set Client State - * @description Sets the client state + * @description Sets the client state for the current user (or system user if not authenticated) */ post: operations["set_client_state"]; delete?: never; @@ -2090,7 +2238,7 @@ export type paths = { put?: never; /** * Delete Client State - * @description Deletes the client state + * @description Deletes the client state for the current user (or system user if not authenticated) */ post: operations["delete_client_state"]; delete?: never; @@ -2703,6 +2851,11 @@ export type components = { * @description The name of the board. */ board_name: string; + /** + * User Id + * @description The user ID of the board owner. + */ + user_id: string; /** * Created At * @description The created timestamp of the board. @@ -2738,6 +2891,11 @@ export type components = { * @description The number of assets in the board. */ asset_count: number; + /** + * Owner Username + * @description The username of the board owner (for admin view). + */ + owner_username?: string | null; }; /** * BoardField @@ -13483,6 +13641,12 @@ export type components = { * @default null */ destination: string | null; + /** + * User Id + * @description The ID of the user who created the queue item + * @default system + */ + user_id: string; /** * Session Id * @description The ID of the session (aka graph execution state) @@ -13541,6 +13705,12 @@ export type components = { * @default null */ destination: string | null; + /** + * User Id + * @description The ID of the user who created the queue item + * @default system + */ + user_id: string; /** * Session Id * @description The ID of the session (aka graph execution state) @@ -13841,6 +14011,12 @@ export type components = { * @default null */ destination: string | null; + /** + * User Id + * @description The ID of the user who created the queue item + * @default system + */ + user_id: string; /** * Session Id * @description The ID of the session (aka graph execution state) @@ -13910,6 +14086,12 @@ export type components = { * @default null */ destination: string | null; + /** + * User Id + * @description The ID of the user who created the queue item + * @default system + */ + user_id: string; /** * Session Id * @description The ID of the session (aka graph execution state) @@ -13990,6 +14172,7 @@ export type components = { * scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes. * unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production. * allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation. + * multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. */ InvokeAIAppConfig: { /** @@ -14357,6 +14540,12 @@ export type components = { * @default true */ allow_unknown_models?: boolean; + /** + * Multiuser + * @description Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization. + * @default false + */ + multiuser?: boolean; }; /** * InvokeAIAppConfigWithSetFields @@ -16770,6 +16959,57 @@ export type components = { * @enum {integer} */ LogLevel: 0 | 10 | 20 | 30 | 40 | 50; + /** + * LoginRequest + * @description Request body for user login. + */ + LoginRequest: { + /** + * Email + * @description User email address + */ + email: string; + /** + * Password + * @description User password + */ + password: string; + /** + * Remember Me + * @description Whether to extend session duration + * @default false + */ + remember_me?: boolean; + }; + /** + * LoginResponse + * @description Response from successful login. + */ + LoginResponse: { + /** + * Token + * @description JWT access token + */ + token: string; + /** @description User information */ + user: components["schemas"]["UserDTO"]; + /** + * Expires In + * @description Token expiration time in seconds + */ + expires_in: number; + }; + /** + * LogoutResponse + * @description Response from logout. + */ + LogoutResponse: { + /** + * Success + * @description Whether logout was successful + */ + success: boolean; + }; /** LoraModelDefaultSettings */ LoraModelDefaultSettings: { /** @@ -21652,6 +21892,12 @@ export type components = { * @default null */ destination: string | null; + /** + * User Id + * @description The ID of the user who created the queue item + * @default system + */ + user_id: string; /** * Status * @description The new status of the queue item @@ -23895,6 +24141,22 @@ export type components = { * @description The id of the queue with which this item is associated */ queue_id: string; + /** + * User Id + * @description The id of the user who created this queue item + * @default system + */ + user_id?: string; + /** + * User Display Name + * @description The display name of the user who created this queue item, if available + */ + user_display_name?: string | null; + /** + * User Email + * @description The email of the user who created this queue item, if available + */ + user_email?: string | null; /** * Field Values * @description The field values that were used for this queue item @@ -23962,6 +24224,66 @@ export type components = { * @description Total number of queue items */ total: number; + /** + * User Pending + * @description Number of queue items with status 'pending' for the current user + */ + user_pending?: number | null; + /** + * User In Progress + * @description Number of queue items with status 'in_progress' for the current user + */ + user_in_progress?: number | null; + }; + /** + * SetupRequest + * @description Request body for initial admin setup. + */ + SetupRequest: { + /** + * Email + * @description Admin email address + */ + email: string; + /** + * Display Name + * @description Admin display name + */ + display_name?: string | null; + /** + * Password + * @description Admin password + */ + password: string; + }; + /** + * SetupResponse + * @description Response from successful admin setup. + */ + SetupResponse: { + /** + * Success + * @description Whether setup was successful + */ + success: boolean; + /** @description Created admin user information */ + user: components["schemas"]["UserDTO"]; + }; + /** + * SetupStatusResponse + * @description Response for setup status check. + */ + SetupStatusResponse: { + /** + * Setup Required + * @description Whether initial setup is required + */ + setup_required: boolean; + /** + * Multiuser Enabled + * @description Whether multiuser mode is enabled + */ + multiuser_enabled: boolean; }; /** * Show Image @@ -26300,6 +26622,56 @@ export type components = { */ unstarred_images: string[]; }; + /** + * UserDTO + * @description User data transfer object. + */ + UserDTO: { + /** + * User Id + * @description Unique user identifier + */ + user_id: string; + /** + * Email + * @description User email address + */ + email: string; + /** + * Display Name + * @description Display name + */ + display_name?: string | null; + /** + * Is Admin + * @description Whether user has admin privileges + * @default false + */ + is_admin?: boolean; + /** + * Is Active + * @description Whether user account is active + * @default true + */ + is_active?: boolean; + /** + * Created At + * Format: date-time + * @description When the user was created + */ + created_at: string; + /** + * Updated At + * Format: date-time + * @description When the user was last updated + */ + updated_at: string; + /** + * Last Login At + * @description When user last logged in + */ + last_login_at?: string | null; + }; /** VAEField */ VAEField: { /** @description Info to load vae submodel */ @@ -28053,6 +28425,132 @@ export type components = { }; export type $defs = Record; export interface operations { + get_setup_status_api_v1_auth_status_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SetupStatusResponse"]; + }; + }; + }; + }; + login_api_v1_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LoginResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logout_api_v1_auth_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogoutResponse"]; + }; + }; + }; + }; + get_current_user_info_api_v1_auth_me_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + }; + }; + setup_admin_api_v1_auth_setup_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetupRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SetupResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; parse_dynamicprompts: { parameters: { query?: never; @@ -32455,7 +32953,7 @@ export interface operations { }; header?: never; path: { - /** @description The queue id to perform this operation on */ + /** @description The queue id (ignored, kept for backwards compatibility) */ queue_id: string; }; cookie?: never; @@ -32490,7 +32988,7 @@ export interface operations { }; header?: never; path: { - /** @description The queue id to perform this operation on */ + /** @description The queue id (ignored, kept for backwards compatibility) */ queue_id: string; }; cookie?: never; @@ -32526,7 +33024,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description The queue id to perform this operation on */ + /** @description The queue id (ignored, kept for backwards compatibility) */ queue_id: string; }; cookie?: never; diff --git a/invokeai/frontend/web/src/services/events/setEventListeners.tsx b/invokeai/frontend/web/src/services/events/setEventListeners.tsx index 677f4d658d..6fb50e4902 100644 --- a/invokeai/frontend/web/src/services/events/setEventListeners.tsx +++ b/invokeai/frontend/web/src/services/events/setEventListeners.tsx @@ -408,10 +408,13 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis ); // Invalidate caches for things we cannot easily update + // Invalidate SessionQueueStatus to refetch with user-specific counts const tagsToInvalidate: ApiTagDescription[] = [ 'CurrentSessionQueueItem', 'NextSessionQueueItem', 'InvocationCacheStatus', + 'SessionQueueStatus', + 'SessionQueueItemIdList', { type: 'SessionQueueItem', id: item_id }, { type: 'SessionQueueItem', id: LIST_TAG }, { type: 'SessionQueueItem', id: LIST_ALL_TAG }, @@ -421,16 +424,6 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis tagsToInvalidate.push({ type: 'QueueCountsByDestination', id: destination }); } dispatch(queueApi.util.invalidateTags(tagsToInvalidate)); - dispatch( - queueApi.util.updateQueryData('getQueueStatus', undefined, (draft) => { - draft.queue = data.queue_status; - }) - ); - dispatch( - queueApi.util.updateQueryData('getBatchStatus', { batch_id: data.batch_id }, (draft) => { - Object.assign(draft, data.batch_status); - }) - ); if (status === 'in_progress') { forEach($nodeExecutionStates.get(), (nes) => { @@ -464,14 +457,55 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis socket.on('queue_cleared', (data) => { log.debug({ data }, 'Queue cleared'); + dispatch( + queueApi.util.invalidateTags([ + 'SessionQueueStatus', + 'SessionProcessorStatus', + 'BatchStatus', + 'CurrentSessionQueueItem', + 'NextSessionQueueItem', + 'QueueCountsByDestination', + 'SessionQueueItemIdList', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ]) + ); }); socket.on('batch_enqueued', (data) => { log.debug({ data }, 'Batch enqueued'); + dispatch( + queueApi.util.invalidateTags([ + 'SessionQueueStatus', + 'CurrentSessionQueueItem', + 'NextSessionQueueItem', + 'QueueCountsByDestination', + 'SessionQueueItemIdList', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ]) + ); }); socket.on('queue_items_retried', (data) => { log.debug({ data }, 'Queue items retried'); + const tagsToInvalidate: ApiTagDescription[] = [ + 'SessionQueueStatus', + 'BatchStatus', + 'CurrentSessionQueueItem', + 'NextSessionQueueItem', + 'QueueCountsByDestination', + 'SessionQueueItemIdList', + { type: 'SessionQueueItem', id: LIST_TAG }, + { type: 'SessionQueueItem', id: LIST_ALL_TAG }, + ]; + // Invalidate each retried item specifically + if (data.retried_item_ids) { + for (const itemId of data.retried_item_ids) { + tagsToInvalidate.push({ type: 'SessionQueueItem', id: itemId }); + } + } + dispatch(queueApi.util.invalidateTags(tagsToInvalidate)); }); socket.on('recall_parameters_updated', (data) => { diff --git a/invokeai/frontend/web/src/services/events/useSocketIO.ts b/invokeai/frontend/web/src/services/events/useSocketIO.ts index cdbfb88224..dcbe2501f3 100644 --- a/invokeai/frontend/web/src/services/events/useSocketIO.ts +++ b/invokeai/frontend/web/src/services/events/useSocketIO.ts @@ -30,11 +30,18 @@ export const useSocketIO = () => { }, []); const socketOptions = useMemo(() => { + const token = localStorage.getItem('auth_token'); const options: Partial = { timeout: 60000, - path: `${window.location.pathname}ws/socket.io`, + path: '/ws/socket.io', autoConnect: false, // achtung! removing this breaks the dynamic middleware forceNew: true, + auth: token ? { token } : undefined, + extraHeaders: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, }; return options; diff --git a/mkdocs.yml b/mkdocs.yml index 656baec9c3..4c6d3039cf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -124,7 +124,6 @@ nav: - Docker: 'installation/docker.md' - PatchMatch: 'installation/patchmatch.md' - Models: 'installation/models.md' - - Legacy Scripts: 'installation/legacy_scripts.md' - Workflows & Nodes: - Nodes Overview: 'nodes/overview.md' - Workflow Editor Basics: 'nodes/NODES.md' @@ -137,9 +136,16 @@ nav: - Invocation API: 'nodes/invocation-api.md' - Configuration: 'configuration.md' - Features: + - New to InvokeAI?: 'help/gettingStartedWithAI.md' - Low VRAM mode: 'features/low-vram.md' - Database: 'features/database.md' - - New to InvokeAI?: 'help/gettingStartedWithAI.md' + - Gallery: 'features/gallery.md' + - Hot Keys: 'features/hotkeys.md' + - Multi-User Mode: + - User Guide: 'multiuser/user_guide.md' + - Administrator Guide: 'multiuser/admin_guide.md' + - API Guide: 'multiuser/api_guide.md' + - Specification: 'multiuser/specification.md' - Contributing: - Overview: 'contributing/index.md' - Code of Conduct: 'CODE_OF_CONDUCT.md' @@ -148,7 +154,10 @@ nav: - Overview: 'contributing/contribution_guides/development.md' - New Contributors: 'contributing/contribution_guides/newContributorChecklist.md' - Model Manager v2: 'contributing/MODEL_MANAGER.md' + - Multiuser Mode: 'multiuser/specification.md' - Local Development: 'contributing/LOCAL_DEVELOPMENT.md' + - System Architecture: 'contributing/ARCHITECTURE.md' + - Hotkeys: 'contributing/HOTKEYS.md' - Testing: 'contributing/TESTS.md' - Frontend: - Overview: 'contributing/frontend/index.md' diff --git a/pyproject.toml b/pyproject.toml index adfe5982ba..018cf1970d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,14 +65,18 @@ dependencies = [ # Auxiliary dependencies, pinned only if necessary. "blake3", + "bcrypt<4.0.0", "Deprecated", "dnspython", "dynamicprompts", "einops", + "email-validator>=2.0.0", + "passlib[bcrypt]>=1.7.4", "picklescan", "pillow", "prompt-toolkit", "pypatchmatch", + "python-jose[cryptography]>=3.3.0", "python-multipart", "requests", "semver~=3.0.1", @@ -159,6 +163,10 @@ explicit = true [project.scripts] "invokeai-web" = "invokeai.app.run_app:run_app" +"invoke-useradd" = "invokeai.app.util.user_management:useradd" +"invoke-userdel" = "invokeai.app.util.user_management:userdel" +"invoke-userlist" = "invokeai.app.util.user_management:userlist" +"invoke-usermod" = "invokeai.app.util.user_management:usermod" [project.urls] "Homepage" = "https://invoke-ai.github.io/InvokeAI/" diff --git a/tests/app/routers/test_auth.py b/tests/app/routers/test_auth.py new file mode 100644 index 0000000000..0949048e60 --- /dev/null +++ b/tests/app/routers/test_auth.py @@ -0,0 +1,336 @@ +"""Integration tests for authentication router endpoints.""" + +import os +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.auth.token_service import set_jwt_secret +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.users.users_common import UserCreateRequest + + +@pytest.fixture(autouse=True, scope="module") +def setup_jwt_secret(): + """Set up JWT secret for all tests in this module.""" + # Use a test secret key + set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production") + + +@pytest.fixture(autouse=True, scope="module") +def client(invokeai_root_dir: Path) -> TestClient: + """Create a test client for the FastAPI app.""" + os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix() + return TestClient(app) + + +@pytest.fixture(autouse=True) +def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None: + """Enable multiuser mode for auth tests. + + Auth tests need multiuser mode enabled since the login/setup endpoints + return 403 when multiuser is disabled. + """ + mock_invoker.services.configuration.multiuser = True + + +class MockApiDependencies(ApiDependencies): + """Mock API dependencies for testing.""" + + invoker: Invoker + + def __init__(self, invoker) -> None: + self.invoker = invoker + + +def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str: + """Helper to create a test user and return user_id.""" + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name="Test User", + password=password, + is_admin=False, + ) + user = user_service.create(user_data) + return user.user_id + + +def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str: + """Helper to create a test admin user and return user_id.""" + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name="Admin User", + password=password, + is_admin=True, + ) + user = user_service.create(user_data) + return user.user_id + + +def test_login_success(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test successful login with valid credentials.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create a test user + setup_test_user(mock_invoker, "test@example.com", "TestPass123") + + # Attempt login + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + + assert response.status_code == 200 + json_response = response.json() + assert "token" in json_response + assert "user" in json_response + assert "expires_in" in json_response + assert json_response["user"]["email"] == "test@example.com" + assert json_response["user"]["is_admin"] is False + + +def test_login_with_remember_me(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test login with remember_me flag sets longer expiration.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + setup_test_user(mock_invoker, "test2@example.com", "TestPass123") + + # Login with remember_me=True + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test2@example.com", + "password": "TestPass123", + "remember_me": True, + }, + ) + + assert response.status_code == 200 + json_response = response.json() + # Remember me should give 7 days = 604800 seconds + assert json_response["expires_in"] == 604800 + + +def test_login_invalid_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test login fails with invalid password.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + setup_test_user(mock_invoker, "test3@example.com", "TestPass123") + + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test3@example.com", + "password": "WrongPassword", + "remember_me": False, + }, + ) + + assert response.status_code == 401 + assert "Incorrect email or password" in response.json()["detail"] + + +def test_login_nonexistent_user(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test login fails with nonexistent user.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + response = client.post( + "/api/v1/auth/login", + json={ + "email": "nonexistent@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + + assert response.status_code == 401 + assert "Incorrect email or password" in response.json()["detail"] + + +def test_login_inactive_user(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test login fails with inactive user.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + user_id = setup_test_user(mock_invoker, "inactive@example.com", "TestPass123") + + # Deactivate the user + user_service = mock_invoker.services.users + from invokeai.app.services.users.users_common import UserUpdateRequest + + user_service.update(user_id, UserUpdateRequest(is_active=False)) + + response = client.post( + "/api/v1/auth/login", + json={ + "email": "inactive@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + + assert response.status_code == 403 + assert "disabled" in response.json()["detail"] + + +def test_logout(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test logout endpoint.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + setup_test_user(mock_invoker, "test4@example.com", "TestPass123") + + # Login first to get token + login_response = client.post( + "/api/v1/auth/login", + json={ + "email": "test4@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + token = login_response.json()["token"] + + # Logout with token + response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + assert response.json()["success"] is True + + +def test_logout_without_token(client: TestClient) -> None: + """Test logout fails without authentication token.""" + response = client.post("/api/v1/auth/logout") + + assert response.status_code == 401 + + +def test_get_current_user_info(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test getting current user info with valid token.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + setup_test_user(mock_invoker, "test5@example.com", "TestPass123") + + # Login to get token + login_response = client.post( + "/api/v1/auth/login", + json={ + "email": "test5@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + token = login_response.json()["token"] + + # Get user info + response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + json_response = response.json() + assert json_response["email"] == "test5@example.com" + assert json_response["display_name"] == "Test User" + assert json_response["is_admin"] is False + + +def test_get_current_user_info_without_token(client: TestClient) -> None: + """Test getting user info fails without token.""" + response = client.get("/api/v1/auth/me") + + assert response.status_code == 401 + + +def test_get_current_user_info_invalid_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test getting user info fails with invalid token.""" + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + response = client.get("/api/v1/auth/me", headers={"Authorization": "Bearer invalid_token"}) + + assert response.status_code == 401 + + +def test_setup_admin_first_time(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test setting up first admin user.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + response = client.post( + "/api/v1/auth/setup", + json={ + "email": "admin@example.com", + "display_name": "Admin User", + "password": "AdminPass123", + }, + ) + + assert response.status_code == 200 + json_response = response.json() + assert json_response["success"] is True + assert json_response["user"]["email"] == "admin@example.com" + assert json_response["user"]["is_admin"] is True + + +def test_setup_admin_already_exists(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test setup fails when admin already exists.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create first admin + setup_test_admin(mock_invoker, "admin1@example.com", "AdminPass123") + + # Try to setup another admin + response = client.post( + "/api/v1/auth/setup", + json={ + "email": "admin2@example.com", + "display_name": "Second Admin", + "password": "AdminPass123", + }, + ) + + assert response.status_code == 400 + assert "already configured" in response.json()["detail"] + + +def test_setup_admin_weak_password(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test setup fails with weak password.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + response = client.post( + "/api/v1/auth/setup", + json={ + "email": "admin3@example.com", + "display_name": "Admin User", + "password": "weak", + }, + ) + + assert response.status_code == 400 + assert "Password" in response.json()["detail"] + + +def test_admin_user_token_has_admin_flag(monkeypatch: Any, mock_invoker: Invoker, client: TestClient) -> None: + """Test that admin user login returns token with admin flag.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + setup_test_admin(mock_invoker, "admin4@example.com", "AdminPass123") + + response = client.post( + "/api/v1/auth/login", + json={ + "email": "admin4@example.com", + "password": "AdminPass123", + "remember_me": False, + }, + ) + + assert response.status_code == 200 + json_response = response.json() + assert json_response["user"]["is_admin"] is True diff --git a/tests/app/routers/test_boards_multiuser.py b/tests/app/routers/test_boards_multiuser.py new file mode 100644 index 0000000000..d5c4848156 --- /dev/null +++ b/tests/app/routers/test_boards_multiuser.py @@ -0,0 +1,459 @@ +"""Tests for multiuser boards functionality.""" + +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.invoker import Invoker +from invokeai.app.services.users.users_common import UserCreateRequest + + +class MockApiDependencies(ApiDependencies): + """Mock API dependencies for testing.""" + + invoker: Invoker + + def __init__(self, invoker: Invoker) -> None: + self.invoker = invoker + + +@pytest.fixture +def setup_jwt_secret(): + """Initialize JWT secret for token generation.""" + from invokeai.app.services.auth.token_service import set_jwt_secret + + # Use a test secret key + set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production") + + +@pytest.fixture +def client(): + """Create a test client.""" + return TestClient(app) + + +def setup_test_user( + mock_invoker: Invoker, + email: str, + display_name: str, + password: str = "TestPass123", + is_admin: bool = False, +) -> str: + """Helper to create a test user and return user_id.""" + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name=display_name, + password=password, + is_admin=is_admin, + ) + user = user_service.create(user_data) + return user.user_id + + +def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str: + """Helper to login and get a user token.""" + response = client.post( + "/api/v1/auth/login", + json={"email": email, "password": password, "remember_me": False}, + ) + assert response.status_code == 200 + return response.json()["token"] + + +@pytest.fixture +def enable_multiuser_for_tests(monkeypatch: Any, mock_invoker: Invoker): + """Enable multiuser mode and patch ApiDependencies for all relevant routers.""" + mock_invoker.services.configuration.multiuser = True + # Provide a mock board_images service so delete/image_names endpoints don't 500 + mock_board_images = MagicMock() + mock_board_images.get_all_board_image_names_for_board.return_value = [] + mock_invoker.services.board_images = mock_board_images + + 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.boards.ApiDependencies", mock_deps) + yield + + +@pytest.fixture +def admin_token(setup_jwt_secret: None, enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient): + """Create an admin user and return a login token.""" + setup_test_user(mock_invoker, "admin@test.com", "Test Admin", is_admin=True) + return get_user_token(client, "admin@test.com") + + +@pytest.fixture +def user1_token(enable_multiuser_for_tests: Any, mock_invoker: Invoker, client: TestClient, admin_token: str): + """Create a regular user and return a login token.""" + setup_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_for_tests: Any, mock_invoker: Invoker, client: TestClient, admin_token: str): + """Create a second regular user and return a login token.""" + setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False) + return get_user_token(client, "user2@test.com") + + +# --------------------------------------------------------------------------- +# Basic auth requirement tests +# --------------------------------------------------------------------------- + + +def test_create_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that creating a board requires authentication.""" + response = client.post("/api/v1/boards/?board_name=Test+Board") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_list_boards_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that listing boards requires authentication.""" + response = client.get("/api/v1/boards/?all=true") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_get_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that getting a board requires authentication.""" + response = client.get("/api/v1/boards/some-board-id") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_update_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that updating a board requires authentication.""" + response = client.patch("/api/v1/boards/some-board-id", json={"board_name": "New Name"}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_delete_board_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that deleting a board requires authentication.""" + response = client.delete("/api/v1/boards/some-board-id") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_list_board_image_names_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that listing board image names requires authentication.""" + response = client.get("/api/v1/boards/some-board-id/image_names") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# --------------------------------------------------------------------------- +# Basic create / list tests +# --------------------------------------------------------------------------- + + +def test_create_board_with_auth(client: TestClient, admin_token: str): + """Test that authenticated users can create boards.""" + response = client.post( + "/api/v1/boards/?board_name=My+Test+Board", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["board_name"] == "My Test Board" + assert "board_id" in data + + +def test_list_boards_with_auth(client: TestClient, admin_token: str): + """Test that authenticated users can list their boards.""" + # First create a board + client.post( + "/api/v1/boards/?board_name=Listed+Board", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + # Now list boards + response = client.get( + "/api/v1/boards/?all=true", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + boards = response.json() + assert isinstance(boards, list) + board_names = [b["board_name"] for b in boards] + assert "Listed Board" in board_names + + +def test_user_boards_are_isolated(client: TestClient, admin_token: str, user1_token: str): + """Test that boards are isolated between users.""" + # Admin creates a board + admin_response = client.post( + "/api/v1/boards/?board_name=Admin+Board", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert admin_response.status_code == status.HTTP_201_CREATED + + # Admin can see their own board + list_response = client.get( + "/api/v1/boards/?all=true", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert list_response.status_code == status.HTTP_200_OK + boards = list_response.json() + board_names = [b["board_name"] for b in boards] + assert "Admin Board" in board_names + + # user1 should not see admin's board in their own listing + user1_list = client.get( + "/api/v1/boards/?all=true", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert user1_list.status_code == status.HTTP_200_OK + user1_board_names = [b["board_name"] for b in user1_list.json()] + assert "Admin Board" not in user1_board_names + + +# --------------------------------------------------------------------------- +# Ownership enforcement: get_board +# --------------------------------------------------------------------------- + + +def test_get_board_owner_succeeds(client: TestClient, user1_token: str): + """Test that the board owner can retrieve their own board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.get( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["board_id"] == board_id + + +def test_get_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str): + """Test that a non-owner cannot retrieve another user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Private+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.get( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_get_board_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str): + """Test that an admin can retrieve any user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+For+Admin", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.get( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + +# --------------------------------------------------------------------------- +# Ownership enforcement: update_board +# --------------------------------------------------------------------------- + + +def test_update_board_owner_succeeds(client: TestClient, user1_token: str): + """Test that the board owner can update their own board.""" + create = client.post( + "/api/v1/boards/?board_name=Original+Name", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_name": "Updated Name"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["board_name"] == "Updated Name" + + +def test_update_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str): + """Test that a non-owner cannot update another user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+To+Update", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_name": "Hijacked Name"}, + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_update_board_admin_can_update_any_board(client: TestClient, admin_token: str, user1_token: str): + """Test that an admin can update any user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+Admin+Update", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.patch( + f"/api/v1/boards/{board_id}", + json={"board_name": "Admin Updated Name"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["board_name"] == "Admin Updated Name" + + +# --------------------------------------------------------------------------- +# Ownership enforcement: delete_board +# --------------------------------------------------------------------------- + + +def test_delete_board_owner_succeeds(client: TestClient, user1_token: str): + """Test that the board owner can delete their own board.""" + create = client.post( + "/api/v1/boards/?board_name=Board+To+Delete", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.delete( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["board_id"] == board_id + + +def test_delete_board_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str): + """Test that a non-owner cannot delete another user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+To+Delete", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.delete( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_delete_board_admin_can_delete_any_board(client: TestClient, admin_token: str, user1_token: str): + """Test that an admin can delete any user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+Admin+Delete", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.delete( + f"/api/v1/boards/{board_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + +# --------------------------------------------------------------------------- +# Ownership enforcement: list_all_board_image_names +# --------------------------------------------------------------------------- + + +def test_list_board_image_names_owner_succeeds(client: TestClient, user1_token: str): + """Test that the board owner can list image names for their board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Images+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.get( + f"/api/v1/boards/{board_id}/image_names", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.json(), list) + + +def test_list_board_image_names_other_user_forbidden(client: TestClient, user1_token: str, user2_token: str): + """Test that a non-owner cannot list image names for another user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Private+Images+Board", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.get( + f"/api/v1/boards/{board_id}/image_names", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_list_board_image_names_admin_can_access_any_board(client: TestClient, admin_token: str, user1_token: str): + """Test that an admin can list image names for any user's board.""" + create = client.post( + "/api/v1/boards/?board_name=User1+Board+Admin+Images", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert create.status_code == status.HTTP_201_CREATED + board_id = create.json()["board_id"] + + response = client.get( + f"/api/v1/boards/{board_id}/image_names", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + +def test_list_board_image_names_none_board_no_auth_check(enable_multiuser_for_tests: Any, client: TestClient): + """Test that listing image names for the 'none' board requires auth but no ownership check.""" + # The 'none' board is the uncategorized images board — no ownership check needed, + # but auth is still required in multiuser mode. + response = client.get("/api/v1/boards/none/image_names") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# --------------------------------------------------------------------------- +# Misc tests +# --------------------------------------------------------------------------- + + +def test_enqueue_batch_requires_auth(enable_multiuser_for_tests: Any, client: TestClient): + """Test that enqueuing a batch requires authentication.""" + response = client.post( + "/api/v1/queue/default/enqueue_batch", + json={ + "batch": { + "batch_id": "test-batch", + "data": [], + "graph": {"nodes": {}, "edges": []}, + }, + "prepend": False, + }, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/app/routers/test_client_state_multiuser.py b/tests/app/routers/test_client_state_multiuser.py new file mode 100644 index 0000000000..814c9182fe --- /dev/null +++ b/tests/app/routers/test_client_state_multiuser.py @@ -0,0 +1,299 @@ +"""Tests for multiuser client state functionality.""" + +from typing import Any + +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.invoker import Invoker +from invokeai.app.services.users.users_common import UserCreateRequest + + +@pytest.fixture +def client(): + """Create a test client.""" + return TestClient(app) + + +class MockApiDependencies(ApiDependencies): + """Mock API dependencies for testing.""" + + invoker: Invoker + + def __init__(self, invoker: Invoker) -> None: + self.invoker = invoker + + +def setup_test_user( + mock_invoker: Invoker, email: str, display_name: str, password: str = "TestPass123", is_admin: bool = False +) -> str: + """Helper to create a test user and return user_id.""" + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name=display_name, + password=password, + is_admin=is_admin, + ) + user = user_service.create(user_data) + return user.user_id + + +def get_user_token(client: TestClient, email: str, password: str = "TestPass123") -> str: + """Helper to login and get a user token.""" + response = client.post( + "/api/v1/auth/login", + json={ + "email": email, + "password": password, + "remember_me": False, + }, + ) + assert response.status_code == 200 + return response.json()["token"] + + +@pytest.fixture +def admin_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Get an admin token for testing.""" + # Enable multiuser mode for auth endpoints + mock_invoker.services.configuration.multiuser = True + + # Mock ApiDependencies for auth and client_state routers + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create admin user + setup_test_user(mock_invoker, "admin@test.com", "Admin User", is_admin=True) + + return get_user_token(client, "admin@test.com") + + +@pytest.fixture +def user1_token(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str): + """Get a token for test user 1.""" + # Create a regular user + setup_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(monkeypatch: Any, mock_invoker: Invoker, client: TestClient, admin_token: str): + """Get a token for test user 2.""" + # Create another regular user + setup_test_user(mock_invoker, "user2@test.com", "User Two", is_admin=False) + + return get_user_token(client, "user2@test.com") + + +def test_get_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker): + """Test that getting client state without authentication uses the system user.""" + # Mock ApiDependencies + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Set a value for the system user directly + mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value") + + # Get without authentication - should return system user's value + response = client.get("/api/v1/client_state/default/get_by_key?key=test_key") + assert response.status_code == status.HTTP_200_OK + assert response.json() == "system_value" + + +def test_set_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker): + """Test that setting client state without authentication uses the system user.""" + # Mock ApiDependencies + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Set without authentication - should set for system user + response = client.post( + "/api/v1/client_state/default/set_by_key?key=test_key", + json="unauthenticated_value", + ) + assert response.status_code == status.HTTP_200_OK + + # Verify it was set for system user + value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key") + assert value == "unauthenticated_value" + + +def test_delete_client_state_without_auth_uses_system_user(client: TestClient, monkeypatch, mock_invoker: Invoker): + """Test that deleting client state without authentication uses the system user.""" + # Mock ApiDependencies + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.routers.client_state.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Set a value for system user + mock_invoker.services.client_state_persistence.set_by_key("system", "test_key", "system_value") + + # Delete without authentication - should delete system user's data + response = client.post("/api/v1/client_state/default/delete") + assert response.status_code == status.HTTP_200_OK + + # Verify it was deleted for system user + value = mock_invoker.services.client_state_persistence.get_by_key("system", "test_key") + assert value is None + + +def test_set_and_get_client_state(client: TestClient, admin_token: str): + """Test that authenticated users can set and get their client state.""" + # Set a value + set_response = client.post( + "/api/v1/client_state/default/set_by_key?key=test_key", + json="test_value", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert set_response.status_code == status.HTTP_200_OK + assert set_response.json() == "test_value" + + # Get the value back + get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=test_key", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json() == "test_value" + + +def test_client_state_isolation_between_users(client: TestClient, user1_token: str, user2_token: str): + """Test that client state is isolated between different users.""" + # User 1 sets a value + user1_set_response = client.post( + "/api/v1/client_state/default/set_by_key?key=shared_key", + json="user1_value", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert user1_set_response.status_code == status.HTTP_200_OK + + # User 2 sets a different value for the same key + user2_set_response = client.post( + "/api/v1/client_state/default/set_by_key?key=shared_key", + json="user2_value", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert user2_set_response.status_code == status.HTTP_200_OK + + # User 1 should still see their own value + user1_get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=shared_key", + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert user1_get_response.status_code == status.HTTP_200_OK + assert user1_get_response.json() == "user1_value" + + # User 2 should see their own value + user2_get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=shared_key", + headers={"Authorization": f"Bearer {user2_token}"}, + ) + assert user2_get_response.status_code == status.HTTP_200_OK + assert user2_get_response.json() == "user2_value" + + +def test_get_nonexistent_key_returns_null(client: TestClient, admin_token: str): + """Test that getting a nonexistent key returns null.""" + response = client.get( + "/api/v1/client_state/default/get_by_key?key=nonexistent_key", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() is None + + +def test_delete_client_state(client: TestClient, admin_token: str): + """Test that users can delete their own client state.""" + # Set some values + client.post( + "/api/v1/client_state/default/set_by_key?key=key1", + json="value1", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + client.post( + "/api/v1/client_state/default/set_by_key?key=key2", + json="value2", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + # Verify values exist + get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=key1", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.json() == "value1" + + # Delete all client state + delete_response = client.post( + "/api/v1/client_state/default/delete", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert delete_response.status_code == status.HTTP_200_OK + + # Verify values are gone + get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=key1", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.json() is None + + get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=key2", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.json() is None + + +def test_update_existing_key(client: TestClient, admin_token: str): + """Test that updating an existing key works correctly.""" + # Set initial value + client.post( + "/api/v1/client_state/default/set_by_key?key=update_key", + json="initial_value", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + # Update the value + update_response = client.post( + "/api/v1/client_state/default/set_by_key?key=update_key", + json="updated_value", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert update_response.status_code == status.HTTP_200_OK + + # Verify the updated value + get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=update_key", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json() == "updated_value" + + +def test_complex_json_values(client: TestClient, admin_token: str): + """Test that complex JSON values can be stored and retrieved.""" + import json + + complex_dict = {"params": {"model": "test-model", "steps": 50}, "prompt": "a beautiful landscape"} + complex_value = json.dumps(complex_dict) + + # Set complex value + set_response = client.post( + "/api/v1/client_state/default/set_by_key?key=complex_key", + json=complex_value, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert set_response.status_code == status.HTTP_200_OK + + # Get it back + get_response = client.get( + "/api/v1/client_state/default/get_by_key?key=complex_key", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json() == complex_value diff --git a/tests/app/routers/test_session_queue_sanitization.py b/tests/app/routers/test_session_queue_sanitization.py new file mode 100644 index 0000000000..1b2262d02e --- /dev/null +++ b/tests/app/routers/test_session_queue_sanitization.py @@ -0,0 +1,159 @@ +"""Tests for session queue item sanitization in multiuser mode.""" + +from datetime import datetime + +import pytest + +from invokeai.app.api.routers.session_queue import sanitize_queue_item_for_user +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.services.session_queue.session_queue_common import NodeFieldValue, SessionQueueItem +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from invokeai.app.services.shared.invocation_context import InvocationContext + + +# Define a minimal test invocation for the test +@invocation_output("test_sanitization_output") +class TestSanitizationInvocationOutput(BaseInvocationOutput): + value: str = OutputField(default="") + + +@invocation("test_sanitization", version="1.0.0") +class TestSanitizationInvocation(BaseInvocation): + test_field: str = InputField(default="") + + def invoke(self, context: InvocationContext) -> TestSanitizationInvocationOutput: + return TestSanitizationInvocationOutput(value=self.test_field) + + +@pytest.fixture +def sample_session_queue_item() -> SessionQueueItem: + """Create a sample queue item with full data for testing.""" + graph = Graph() + # Add a simple node to the graph + graph.add_node(TestSanitizationInvocation(id="test_node", test_field="test value")) + + session = GraphExecutionState(id="test_session", graph=graph) + + # Create timestamps for the queue item + now = datetime.now() + + return SessionQueueItem( + item_id=1, + status="pending", + batch_id="batch_123", + session_id="session_123", + queue_id="default", + user_id="user_123", + user_display_name="Test User", + user_email="test@example.com", + field_values=[ + NodeFieldValue(node_path="test_node", field_name="test_field", value="sensitive prompt data"), + ], + session=session, + workflow=None, + created_at=now, + updated_at=now, + started_at=None, + completed_at=None, + ) + + +def test_sanitize_queue_item_for_admin(sample_session_queue_item): + """Test that admins can see all data regardless of user_id.""" + result = sanitize_queue_item_for_user( + queue_item=sample_session_queue_item, + current_user_id="different_user", + is_admin=True, + ) + + # Admin should see everything + assert result.field_values is not None + assert len(result.field_values) == 1 + assert result.session.graph.nodes is not None + assert len(result.session.graph.nodes) == 1 + + +def test_sanitize_queue_item_for_owner(sample_session_queue_item): + """Test that queue item owners can see their own data.""" + result = sanitize_queue_item_for_user( + queue_item=sample_session_queue_item, + current_user_id="user_123", # Same as queue item user_id + is_admin=False, + ) + + # Owner should see everything + assert result.field_values is not None + assert len(result.field_values) == 1 + assert result.session.graph.nodes is not None + assert len(result.session.graph.nodes) == 1 + + +def test_sanitize_queue_item_for_different_user(sample_session_queue_item): + """Test that non-admin users cannot see other users' sensitive data.""" + result = sanitize_queue_item_for_user( + queue_item=sample_session_queue_item, + current_user_id="different_user", + is_admin=False, + ) + + # Non-admin viewing another user's item should have sanitized data + assert result.field_values is None + assert result.workflow is None + # Session should be replaced with empty graph + assert result.session.graph.nodes is not None + assert len(result.session.graph.nodes) == 0 + # Session ID should be preserved + assert result.session.id == "test_session" + + +def test_sanitize_preserves_non_sensitive_fields(sample_session_queue_item): + """Test that sanitization preserves non-sensitive fields.""" + result = sanitize_queue_item_for_user( + queue_item=sample_session_queue_item, + current_user_id="different_user", + is_admin=False, + ) + + # These fields should be preserved + assert result.item_id == 1 + assert result.status == "pending" + assert result.batch_id == "batch_123" + assert result.session_id == "session_123" + assert result.queue_id == "default" + assert result.user_id == "user_123" + assert result.user_display_name == "Test User" + assert result.user_email == "test@example.com" + + +def test_sanitize_system_user_item_for_non_admin(sample_session_queue_item): + """Test that non-admin users cannot see sensitive data from System user's queue items.""" + # Simulate a legacy System user queue item + system_item = sample_session_queue_item.model_copy(update={"user_id": "system"}) + + result = sanitize_queue_item_for_user( + queue_item=system_item, + current_user_id="non_admin_user", + is_admin=False, + ) + + # System user's sensitive fields should be sanitized for non-admin users + assert result.field_values is None + assert result.workflow is None + assert len(result.session.graph.nodes) == 0 + + +def test_sanitize_system_user_item_for_admin(sample_session_queue_item): + """Test that admin users can see full data from System user's queue items.""" + system_item = sample_session_queue_item.model_copy(update={"user_id": "system"}) + + result = sanitize_queue_item_for_user( + queue_item=system_item, + current_user_id="admin_user", + is_admin=True, + ) + + # Admin should see everything including System user's data + assert result.field_values is not None + assert len(result.field_values) == 1 + assert len(result.session.graph.nodes) == 1 diff --git a/tests/app/services/auth/__init__.py b/tests/app/services/auth/__init__.py new file mode 100644 index 0000000000..be14ae18fe --- /dev/null +++ b/tests/app/services/auth/__init__.py @@ -0,0 +1 @@ +"""Tests for authentication services.""" diff --git a/tests/app/services/auth/test_data_isolation.py b/tests/app/services/auth/test_data_isolation.py new file mode 100644 index 0000000000..45a538a6be --- /dev/null +++ b/tests/app/services/auth/test_data_isolation.py @@ -0,0 +1,411 @@ +"""Integration tests for multi-user data isolation. + +Tests to ensure users can only access their own data and cannot access +other users' data unless explicitly shared. +""" + +import os +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.board_records.board_records_common import BoardRecordOrderBy +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.app.services.users.users_common import UserCreateRequest + + +@pytest.fixture(autouse=True, scope="module") +def client(invokeai_root_dir: Path) -> TestClient: + """Create a test client for the FastAPI app.""" + os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix() + return TestClient(app) + + +@pytest.fixture(autouse=True) +def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None: + """Enable multiuser mode for auth tests. + + Auth tests need multiuser mode enabled since the login/setup endpoints + return 403 when multiuser is disabled. + """ + mock_invoker.services.configuration.multiuser = True + + +class MockApiDependencies(ApiDependencies): + """Mock API dependencies for testing.""" + + invoker: Invoker + + def __init__(self, invoker) -> None: + self.invoker = invoker + + +def create_user_and_login( + mock_invoker: Invoker, client: TestClient, monkeypatch: Any, email: str, password: str, is_admin: bool = False +) -> tuple[str, str]: + """Helper to create a user, login, and return (user_id, token).""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name=f"User {email}", + password=password, + is_admin=is_admin, + ) + user = user_service.create(user_data) + + # Login to get token + response = client.post( + "/api/v1/auth/login", + json={ + "email": email, + "password": password, + "remember_me": False, + }, + ) + + assert response.status_code == 200 + token = response.json()["token"] + + return user.user_id, token + + +class TestBoardDataIsolation: + """Tests for board data isolation between users.""" + + def test_user_can_only_see_own_boards(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that users can only see their own boards.""" + monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create two users + user1_id, user1_token = create_user_and_login( + mock_invoker, client, monkeypatch, "user1@example.com", "TestPass123" + ) + user2_id, user2_token = create_user_and_login( + mock_invoker, client, monkeypatch, "user2@example.com", "TestPass123" + ) + + # Create board for user1 + board_service = mock_invoker.services.boards + user1_board = board_service.create(board_name="User 1 Board", user_id=user1_id) + + # Create board for user2 + user2_board = board_service.create(board_name="User 2 Board", user_id=user2_id) + + # User1 should only see their board + user1_boards = board_service.get_many( + user_id=user1_id, + is_admin=False, + order_by=BoardRecordOrderBy.CreatedAt, + direction=SQLiteDirection.Ascending, + ) + + user1_board_ids = [b.board_id for b in user1_boards.items] + assert user1_board.board_id in user1_board_ids + assert user2_board.board_id not in user1_board_ids + + # User2 should only see their board + user2_boards = board_service.get_many( + user_id=user2_id, + is_admin=False, + order_by=BoardRecordOrderBy.CreatedAt, + direction=SQLiteDirection.Ascending, + ) + + user2_board_ids = [b.board_id for b in user2_boards.items] + assert user2_board.board_id in user2_board_ids + assert user1_board.board_id not in user2_board_ids + + def test_user_cannot_access_other_user_board_directly(self, mock_invoker: Invoker): + """Test that users cannot access other users' boards by ID.""" + board_service = mock_invoker.services.boards + user_service = mock_invoker.services.users + + # Create two users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user1 = user_service.create(user1_data) + + user2_data = UserCreateRequest( + email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False + ) + user2 = user_service.create(user2_data) + + # User1 creates a board + user1_board = board_service.create(board_name="User 1 Private Board", user_id=user1.user_id) + + # User2 tries to access user1's board + # The get method should check ownership + try: + retrieved_board = board_service.get(board_id=user1_board.board_id, user_id=user2.user_id) + # If get doesn't check ownership, this test needs to be updated + # or the implementation needs to be fixed + if retrieved_board is not None: + # Board was retrieved - check if it's because of missing authorization check + # This would be a security issue that needs fixing + pytest.fail("User was able to access another user's board without authorization") + except Exception: + # Expected - user2 should not be able to access user1's board + pass + + def test_admin_can_see_all_boards(self, mock_invoker: Invoker): + """Test that admin users can see all boards.""" + board_service = mock_invoker.services.boards + user_service = mock_invoker.services.users + + # Create admin user + admin_data = UserCreateRequest( + email="admin@example.com", display_name="Admin", password="AdminPass123", is_admin=True + ) + admin = user_service.create(admin_data) + + # Create regular user + user_data = UserCreateRequest( + email="user@example.com", display_name="User", password="TestPass123", is_admin=False + ) + user = user_service.create(user_data) + + # User creates a board + board_service.create(board_name="User Board", user_id=user.user_id) + + # Admin creates a board + board_service.create(board_name="Admin Board", user_id=admin.user_id) + + # Admin should be able to get all boards (implementation dependent) + # Note: Current implementation may not have admin override for board listing + # This test documents expected behavior + + +class TestImageDataIsolation: + """Tests for image data isolation between users.""" + + def test_user_images_isolated_from_other_users(self, mock_invoker: Invoker): + """Test that users cannot see other users' images.""" + user_service = mock_invoker.services.users + + # Create two users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user_service.create(user1_data) + + user2_data = UserCreateRequest( + email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False + ) + user_service.create(user2_data) + + # Note: Image service tests would require actual image creation + # which is beyond the scope of basic security testing + # This test documents expected behavior: + # - Images should have user_id field + # - Image queries should filter by user_id + # - Users should not be able to access images by knowing the image_name + + +class TestWorkflowDataIsolation: + """Tests for workflow data isolation between users.""" + + def test_user_workflows_isolated_from_other_users(self, mock_invoker: Invoker): + """Test that users cannot see other users' private workflows.""" + user_service = mock_invoker.services.users + + # Create two users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user_service.create(user1_data) + + user2_data = UserCreateRequest( + email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False + ) + user_service.create(user2_data) + + # Note: Workflow service tests would require workflow creation + # This test documents expected behavior: + # - Workflows should have user_id and is_public fields + # - Private workflows should only be visible to owner + # - Public workflows should be visible to all users + + +class TestQueueDataIsolation: + """Tests for session queue data isolation between users.""" + + def test_user_queue_items_isolated_from_other_users(self, mock_invoker: Invoker): + """Test that users cannot see other users' queue items.""" + user_service = mock_invoker.services.users + + # Create two users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user_service.create(user1_data) + + user2_data = UserCreateRequest( + email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False + ) + user_service.create(user2_data) + + # Note: Queue service tests would require session creation + # This test documents expected behavior: + # - Queue items should have user_id field + # - Users should only see their own queue items + # - Admin should see all queue items + + +class TestSharedBoardAccess: + """Tests for shared board functionality.""" + + @pytest.mark.skip(reason="Shared board functionality not yet fully implemented") + def test_shared_board_access(self, mock_invoker: Invoker): + """Test that users can access boards shared with them.""" + board_service = mock_invoker.services.boards + user_service = mock_invoker.services.users + + # Create two users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user1 = user_service.create(user1_data) + + user2_data = UserCreateRequest( + email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False + ) + user_service.create(user2_data) + + # User1 creates a board + board_service.create(board_name="Shared Board", user_id=user1.user_id) + + # User1 shares the board with user2 + # (This functionality is not yet implemented) + + # User2 should be able to see the shared board + # Expected behavior documented for future implementation + + +class TestAdminAuthorization: + """Tests for admin-only functionality.""" + + def test_regular_user_cannot_create_admin(self, mock_invoker: Invoker): + """Test that regular users cannot create admin accounts.""" + user_service = mock_invoker.services.users + + # Create first admin + admin_data = UserCreateRequest( + email="admin@example.com", display_name="Admin", password="AdminPass123", is_admin=True + ) + user_service.create(admin_data) + + # Try to create another admin (should fail) + with pytest.raises(ValueError, match="already exists"): + another_admin_data = UserCreateRequest( + email="another@example.com", display_name="Another Admin", password="AdminPass123" + ) + user_service.create_admin(another_admin_data) + + def test_regular_user_cannot_list_all_users(self, mock_invoker: Invoker): + """Test that regular users cannot list all users. + + Note: This depends on API endpoint implementation. + At the service level, list_users is available to all callers. + Authorization should be enforced at the API level. + """ + user_service = mock_invoker.services.users + + # Create users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user_service.create(user1_data) + + # Service level does not enforce authorization + # API level should check if caller is admin before allowing user listing + user_service.list_users() + # This will succeed at service level - API must enforce auth + + +class TestDataIntegrity: + """Tests for data integrity in multi-user scenarios.""" + + def test_user_deletion_cascades_to_owned_data(self, mock_invoker: Invoker): + """Test that deleting a user also deletes their owned data.""" + user_service = mock_invoker.services.users + board_service = mock_invoker.services.boards + + # Create user + user_data = UserCreateRequest( + email="deleteme@example.com", display_name="Delete Me", password="TestPass123", is_admin=False + ) + user = user_service.create(user_data) + + # User creates a board + board = board_service.create(board_name="My Board", user_id=user.user_id) + + # Delete user + user_service.delete(user.user_id) + + # Board should be deleted too (CASCADE in database) + # Note: get_dto doesn't take user_id parameter, it gets the board by ID only + # We'll check that it raises an exception or returns None after cascade delete + try: + board_service.get_dto(board_id=board.board_id) + # If we get here, the board wasn't deleted - this is a failure + raise AssertionError("Board should have been deleted by CASCADE") + except Exception: + # Expected - board was deleted by CASCADE + pass + + def test_concurrent_user_operations_maintain_isolation(self, mock_invoker: Invoker): + """Test that concurrent operations from different users maintain data isolation. + + This is a basic test - comprehensive concurrency testing would require + multiple threads/processes and more complex scenarios. + """ + user_service = mock_invoker.services.users + board_service = mock_invoker.services.boards + + # Create two users + user1_data = UserCreateRequest( + email="user1@example.com", display_name="User 1", password="TestPass123", is_admin=False + ) + user1 = user_service.create(user1_data) + + user2_data = UserCreateRequest( + email="user2@example.com", display_name="User 2", password="TestPass123", is_admin=False + ) + user2 = user_service.create(user2_data) + + # Both users create boards + user1_board = board_service.create(board_name="User 1 Board", user_id=user1.user_id) + user2_board = board_service.create(board_name="User 2 Board", user_id=user2.user_id) + + # Verify isolation is maintained + user1_boards = board_service.get_many( + user_id=user1.user_id, + is_admin=False, + order_by=BoardRecordOrderBy.CreatedAt, + direction=SQLiteDirection.Ascending, + ) + user2_boards = board_service.get_many( + user_id=user2.user_id, + is_admin=False, + order_by=BoardRecordOrderBy.CreatedAt, + direction=SQLiteDirection.Ascending, + ) + + user1_board_ids = [b.board_id for b in user1_boards.items] + user2_board_ids = [b.board_id for b in user2_boards.items] + + # Each user should only see their own board + assert user1_board.board_id in user1_board_ids + assert user2_board.board_id not in user1_board_ids + + assert user2_board.board_id in user2_board_ids + assert user1_board.board_id not in user2_board_ids diff --git a/tests/app/services/auth/test_password_utils.py b/tests/app/services/auth/test_password_utils.py new file mode 100644 index 0000000000..64fdeb9d42 --- /dev/null +++ b/tests/app/services/auth/test_password_utils.py @@ -0,0 +1,272 @@ +"""Unit tests for password utilities.""" + +from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password + + +class TestPasswordHashing: + """Tests for password hashing functionality.""" + + def test_hash_password_returns_different_hash_each_time(self): + """Test that hashing the same password twice produces different hashes (due to salt).""" + password = "TestPassword123" + hash1 = hash_password(password) + hash2 = hash_password(password) + + assert hash1 != hash2 + assert hash1 != password + assert hash2 != password + + def test_hash_password_with_special_characters(self): + """Test hashing passwords with special characters.""" + password = "Test!@#$%^&*()_+{}[]|:;<>?,./~`" + hashed = hash_password(password) + + assert hashed is not None + assert verify_password(password, hashed) + + def test_hash_password_with_unicode(self): + """Test hashing passwords with Unicode characters.""" + password = "Test密码123パスワード" + hashed = hash_password(password) + + assert hashed is not None + assert verify_password(password, hashed) + + def test_hash_password_empty_string(self): + """Test hashing empty password (should work but fail validation).""" + password = "" + hashed = hash_password(password) + + assert hashed is not None + assert verify_password(password, hashed) + + def test_hash_password_very_long(self): + """Test hashing very long passwords (bcrypt has 72 byte limit).""" + # Create a password longer than 72 bytes + password = "A" * 100 + hashed = hash_password(password) + + assert hashed is not None + # Verify with original password + assert verify_password(password, hashed) + # Should also match the truncated version + assert verify_password("A" * 72, hashed) + + def test_hash_password_with_newlines(self): + """Test hashing passwords containing newlines.""" + password = "Test\nPassword\n123" + hashed = hash_password(password) + + assert hashed is not None + assert verify_password(password, hashed) + + +class TestPasswordVerification: + """Tests for password verification functionality.""" + + def test_verify_password_correct(self): + """Test verifying correct password.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert verify_password(password, hashed) is True + + def test_verify_password_incorrect(self): + """Test verifying incorrect password.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert verify_password("WrongPassword123", hashed) is False + + def test_verify_password_case_sensitive(self): + """Test that password verification is case-sensitive.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert verify_password("testpassword123", hashed) is False + assert verify_password("TESTPASSWORD123", hashed) is False + + def test_verify_password_whitespace_sensitive(self): + """Test that whitespace matters in password verification.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert verify_password(" TestPassword123", hashed) is False + assert verify_password("TestPassword123 ", hashed) is False + assert verify_password("Test Password123", hashed) is False + + def test_verify_password_with_special_characters(self): + """Test verifying passwords with special characters.""" + password = "Test!@#$%^&*()_+" + hashed = hash_password(password) + + assert verify_password(password, hashed) is True + assert verify_password("Test!@#$%^&*()_+X", hashed) is False + + def test_verify_password_with_unicode(self): + """Test verifying passwords with Unicode.""" + password = "Test密码123" + hashed = hash_password(password) + + assert verify_password(password, hashed) is True + assert verify_password("Test密码124", hashed) is False + + def test_verify_password_empty_against_hashed(self): + """Test verifying empty password.""" + password = "" + hashed = hash_password(password) + + assert verify_password("", hashed) is True + assert verify_password("notEmpty", hashed) is False + + def test_verify_password_invalid_hash_format(self): + """Test verifying password against invalid hash format.""" + password = "TestPassword123" + + # Should return False for invalid hash, not raise exception + assert verify_password(password, "not_a_valid_hash") is False + assert verify_password(password, "") is False + + +class TestPasswordStrengthValidation: + """Tests for password strength validation.""" + + def test_validate_strong_password(self): + """Test validating a strong password.""" + valid, message = validate_password_strength("StrongPass123") + + assert valid is True + assert message == "" + + def test_validate_password_too_short(self): + """Test validating password shorter than 8 characters.""" + valid, message = validate_password_strength("Short1") + + assert valid is False + assert "at least 8 characters" in message + + def test_validate_password_minimum_length(self): + """Test validating password with exactly 8 characters.""" + valid, message = validate_password_strength("Pass123A") + + assert valid is True + assert message == "" + + def test_validate_password_no_uppercase(self): + """Test validating password without uppercase letters.""" + valid, message = validate_password_strength("lowercase123") + + assert valid is False + assert "uppercase" in message.lower() + + def test_validate_password_no_lowercase(self): + """Test validating password without lowercase letters.""" + valid, message = validate_password_strength("UPPERCASE123") + + assert valid is False + assert "lowercase" in message.lower() + + def test_validate_password_no_digits(self): + """Test validating password without digits.""" + valid, message = validate_password_strength("NoDigitsHere") + + assert valid is False + assert "number" in message.lower() + + def test_validate_password_with_special_characters(self): + """Test that special characters are allowed but not required.""" + # With special characters + valid, message = validate_password_strength("Pass!@#$123") + assert valid is True + + # Without special characters (but meets other requirements) + valid, message = validate_password_strength("Password123") + assert valid is True + + def test_validate_password_with_spaces(self): + """Test validating password with spaces.""" + # Password with spaces that meets requirements + valid, message = validate_password_strength("Pass Word 123") + + assert valid is True + assert message == "" + + def test_validate_password_unicode(self): + """Test validating password with Unicode characters.""" + # Unicode with uppercase, lowercase, and digits + valid, message = validate_password_strength("密码Pass123") + + assert valid is True + + def test_validate_password_empty(self): + """Test validating empty password.""" + valid, message = validate_password_strength("") + + assert valid is False + assert "at least 8 characters" in message + + def test_validate_password_all_requirements_barely_met(self): + """Test password that barely meets all requirements.""" + # 8 chars, 1 upper, 1 lower, 1 digit + valid, message = validate_password_strength("Passwor1") + + assert valid is True + assert message == "" + + def test_validate_password_very_long(self): + """Test validating very long password.""" + # Very long password that meets requirements + password = "A" * 50 + "a" * 50 + "1" * 50 + valid, message = validate_password_strength(password) + + assert valid is True + assert message == "" + + +class TestPasswordSecurityProperties: + """Tests for security properties of password handling.""" + + def test_timing_attack_resistance_same_length(self): + """Test that password verification takes similar time for correct and incorrect passwords. + + Note: This is a basic check. Real timing attack resistance requires more sophisticated testing. + """ + import time + + password = "TestPassword123" + hashed = hash_password(password) + + # Measure time for correct password + start = time.perf_counter() + for _ in range(100): + verify_password(password, hashed) + correct_time = time.perf_counter() - start + + # Measure time for incorrect password of same length + start = time.perf_counter() + for _ in range(100): + verify_password("WrongPassword12", hashed) + incorrect_time = time.perf_counter() - start + + # Times should be relatively similar (within 50% difference) + # This is a loose check as bcrypt is designed to be slow and timing-resistant + ratio = max(correct_time, incorrect_time) / min(correct_time, incorrect_time) + assert ratio < 1.5, "Timing difference too large, potential timing attack vulnerability" + + def test_different_hashes_for_same_password(self): + """Test that the same password produces different hashes (salt randomization).""" + password = "TestPassword123" + hashes = {hash_password(password) for _ in range(10)} + + # All hashes should be unique due to random salt + assert len(hashes) == 10 + + def test_hash_output_format(self): + """Test that hash output follows bcrypt format.""" + password = "TestPassword123" + hashed = hash_password(password) + + # Bcrypt hashes start with $2b$ (or other valid bcrypt identifiers) + assert hashed.startswith("$2") + # Bcrypt hashes are 60 characters long + assert len(hashed) == 60 diff --git a/tests/app/services/auth/test_performance.py b/tests/app/services/auth/test_performance.py new file mode 100644 index 0000000000..ad033ac84c --- /dev/null +++ b/tests/app/services/auth/test_performance.py @@ -0,0 +1,474 @@ +"""Performance tests for multiuser authentication system. + +These tests measure the performance overhead of authentication and +ensure the system performs acceptably under load. +""" + +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from logging import Logger + +import pytest + +from invokeai.app.services.auth.password_utils import hash_password, verify_password +from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.users.users_common import UserCreateRequest +from invokeai.app.services.users.users_default import UserService + + +@pytest.fixture +def logger() -> Logger: + """Create a logger for testing.""" + return Logger("test_performance") + + +@pytest.fixture +def user_service(logger: Logger) -> UserService: + """Create a user service with in-memory database for testing.""" + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + + # Create users table + db._conn.execute(""" + CREATE TABLE users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME + ); + """) + db._conn.commit() + + return UserService(db) + + +class TestPasswordPerformance: + """Tests for password hashing and verification performance.""" + + def test_password_hashing_performance(self): + """Test that password hashing completes in reasonable time. + + bcrypt is intentionally slow for security. Each hash should take + approximately 50-100ms on modern hardware. + """ + password = "TestPassword123" + iterations = 10 + + start_time = time.time() + for _ in range(iterations): + hash_password(password) + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Each hash should take between 10ms and 500ms + # (bcrypt is designed to be slow, 50-100ms is typical) + assert 10 < avg_time_ms < 500, f"Password hashing took {avg_time_ms:.2f}ms per hash" + + # Log performance for reference + print(f"\nPassword hashing performance: {avg_time_ms:.2f}ms per hash") + + def test_password_verification_performance(self): + """Test that password verification completes in reasonable time.""" + password = "TestPassword123" + hashed = hash_password(password) + iterations = 10 + + start_time = time.time() + for _ in range(iterations): + verify_password(password, hashed) + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Verification should take similar time to hashing + assert 10 < avg_time_ms < 500, f"Password verification took {avg_time_ms:.2f}ms per verification" + + print(f"Password verification performance: {avg_time_ms:.2f}ms per verification") + + def test_concurrent_password_operations(self): + """Test password operations under concurrent load.""" + password = "TestPassword123" + num_operations = 20 + + def hash_and_verify(): + hashed = hash_password(password) + return verify_password(password, hashed) + + start_time = time.time() + + with ThreadPoolExecutor(max_workers=4) as executor: + futures = [executor.submit(hash_and_verify) for _ in range(num_operations)] + + results = [future.result() for future in as_completed(futures)] + + elapsed_time = time.time() - start_time + + # All operations should succeed + assert all(results) + + # Total time should be less than sequential time due to parallelization + print(f"Concurrent password operations ({num_operations}): {elapsed_time:.2f}s total") + + +class TestTokenPerformance: + """Tests for JWT token performance.""" + + def test_token_creation_performance(self): + """Test that token creation is fast.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + iterations = 1000 + + start_time = time.time() + for _ in range(iterations): + create_access_token(token_data) + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Token creation should be very fast (< 1ms per token) + assert avg_time_ms < 1.0, f"Token creation took {avg_time_ms:.3f}ms per token" + + print(f"\nToken creation performance: {avg_time_ms:.3f}ms per token") + + def test_token_verification_performance(self): + """Test that token verification is fast.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + iterations = 1000 + + start_time = time.time() + for _ in range(iterations): + verify_token(token) + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Token verification should be very fast (< 1ms per verification) + assert avg_time_ms < 1.0, f"Token verification took {avg_time_ms:.3f}ms per verification" + + print(f"Token verification performance: {avg_time_ms:.3f}ms per verification") + + def test_concurrent_token_operations(self): + """Test token operations under concurrent load.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + num_operations = 1000 + + def create_and_verify(): + token = create_access_token(token_data) + verified = verify_token(token) + return verified is not None + + start_time = time.time() + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(create_and_verify) for _ in range(num_operations)] + + results = [future.result() for future in as_completed(futures)] + + elapsed_time = time.time() - start_time + + # All operations should succeed + assert all(results) + + ops_per_second = num_operations / elapsed_time + print(f"Concurrent token operations: {ops_per_second:.0f} ops/second") + + # Should handle at least 1000 operations per second + assert ops_per_second > 1000, f"Only {ops_per_second:.0f} ops/second" + + +class TestAuthenticationOverhead: + """Tests for overall authentication system overhead.""" + + def test_login_flow_performance(self, user_service: UserService): + """Test complete login flow performance.""" + # Create a user + user_data = UserCreateRequest( + email="perf@example.com", + display_name="Performance Test", + password="TestPass123", + is_admin=False, + ) + user_service.create(user_data) + + iterations = 10 + + start_time = time.time() + for _ in range(iterations): + # Simulate login flow + user = user_service.authenticate("perf@example.com", "TestPass123") + assert user is not None + + # Create token + token_data = TokenData( + user_id=user.user_id, + email=user.email, + is_admin=user.is_admin, + ) + token = create_access_token(token_data) + + # Verify token + verified = verify_token(token) + assert verified is not None + + elapsed_time = time.time() - start_time + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Complete login flow should complete in reasonable time + # Most of the time is spent on password verification (50-100ms) + assert avg_time_ms < 500, f"Login flow took {avg_time_ms:.2f}ms" + + print(f"\nComplete login flow performance: {avg_time_ms:.2f}ms per login") + + def test_token_verification_overhead(self): + """Measure overhead of token verification vs no auth.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + iterations = 10000 + + # Measure token verification time + start_time = time.time() + for _ in range(iterations): + verify_token(token) + verification_time = time.time() - start_time + + # Measure baseline (minimal operation) + start_time = time.time() + for _ in range(iterations): + # Simulate minimal auth check + _ = token is not None + baseline_time = time.time() - start_time + + overhead_ms = ((verification_time - baseline_time) / iterations) * 1000 + + # Overhead should be minimal (< 0.1ms per request) + assert overhead_ms < 0.1, f"Token verification adds {overhead_ms:.4f}ms overhead per request" + + print(f"Token verification overhead: {overhead_ms:.4f}ms per request") + + +class TestUserServicePerformance: + """Tests for user service performance.""" + + def test_user_creation_performance(self, user_service: UserService): + """Test user creation performance.""" + iterations = 10 + + start_time = time.time() + for i in range(iterations): + user_data = UserCreateRequest( + email=f"user{i}@example.com", + display_name=f"User {i}", + password="TestPass123", + is_admin=False, + ) + user_service.create(user_data) + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # User creation includes password hashing, so should be ~50-150ms + assert avg_time_ms < 500, f"User creation took {avg_time_ms:.2f}ms per user" + + print(f"\nUser creation performance: {avg_time_ms:.2f}ms per user") + + def test_user_lookup_performance(self, user_service: UserService): + """Test user lookup performance.""" + # Create some users + for i in range(10): + user_data = UserCreateRequest( + email=f"lookup{i}@example.com", + display_name=f"Lookup User {i}", + password="TestPass123", + is_admin=False, + ) + user_service.create(user_data) + + iterations = 1000 + + # Test lookup by email + start_time = time.time() + for _ in range(iterations): + user_service.get_by_email("lookup5@example.com") + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Lookup should be fast (< 1ms with proper indexing) + assert avg_time_ms < 5.0, f"User lookup took {avg_time_ms:.3f}ms per lookup" + + print(f"User lookup by email performance: {avg_time_ms:.3f}ms per lookup") + + def test_user_list_performance(self, user_service: UserService): + """Test user list performance with many users.""" + # Create many users + num_users = 100 + + for i in range(num_users): + user_data = UserCreateRequest( + email=f"listuser{i}@example.com", + display_name=f"List User {i}", + password="TestPass123", + is_admin=False, + ) + user_service.create(user_data) + + # Test listing users + iterations = 10 + + start_time = time.time() + for _ in range(iterations): + user_service.list_users(limit=50) + elapsed_time = time.time() - start_time + + avg_time_ms = (elapsed_time / iterations) * 1000 + + # Listing users should be fast (< 10ms for reasonable page size) + assert avg_time_ms < 50.0, f"User listing took {avg_time_ms:.2f}ms" + + print(f"User listing performance (50 users): {avg_time_ms:.2f}ms per query") + + +class TestConcurrentUserSessions: + """Tests for concurrent user session handling.""" + + def test_multiple_concurrent_logins(self, user_service: UserService): + """Test handling multiple concurrent user logins.""" + # Create test users + num_users = 20 + for i in range(num_users): + user_data = UserCreateRequest( + email=f"concurrent{i}@example.com", + display_name=f"Concurrent User {i}", + password="TestPass123", + is_admin=False, + ) + user_service.create(user_data) + + def authenticate_user(user_index: int): + # Authenticate + user = user_service.authenticate(f"concurrent{user_index}@example.com", "TestPass123") + if user is None: + return False + + # Create token + token_data = TokenData( + user_id=user.user_id, + email=user.email, + is_admin=user.is_admin, + ) + token = create_access_token(token_data) + + # Verify token + verified = verify_token(token) + return verified is not None + + start_time = time.time() + + # Simulate concurrent logins + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(authenticate_user, i) for i in range(num_users)] + + results = [future.result() for future in as_completed(futures)] + + elapsed_time = time.time() - start_time + + # All logins should succeed + assert all(results), "Some concurrent logins failed" + + print(f"\nConcurrent logins ({num_users} users): {elapsed_time:.2f}s total") + + # Should complete in reasonable time + assert elapsed_time < 10.0, f"Concurrent logins took {elapsed_time:.2f}s" + + +@pytest.mark.slow +class TestScalabilityBenchmarks: + """Scalability benchmarks (marked as slow tests).""" + + def test_authentication_under_load(self, user_service: UserService): + """Test authentication system under sustained load.""" + # Create test users + num_users = 50 + for i in range(num_users): + user_data = UserCreateRequest( + email=f"load{i}@example.com", + display_name=f"Load User {i}", + password="TestPass123", + is_admin=False, + ) + user_service.create(user_data) + + def simulate_user_activity(user_index: int, num_requests: int): + success_count = 0 + for _ in range(num_requests): + # Authenticate + user = user_service.authenticate(f"load{user_index}@example.com", "TestPass123") + if user is None: + continue + + # Create and verify token + token_data = TokenData(user_id=user.user_id, email=user.email, is_admin=user.is_admin) + token = create_access_token(token_data) + verified = verify_token(token) + + if verified is not None: + success_count += 1 + + return success_count + + # Simulate sustained load + requests_per_user = 5 + total_requests = num_users * requests_per_user + + start_time = time.time() + + with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(simulate_user_activity, i, requests_per_user) for i in range(num_users)] + + success_counts = [future.result() for future in as_completed(futures)] + + elapsed_time = time.time() - start_time + + total_success = sum(success_counts) + success_rate = (total_success / total_requests) * 100 + requests_per_second = total_requests / elapsed_time + + print("\nLoad test results:") + print(f" Total requests: {total_requests}") + print(f" Success rate: {success_rate:.1f}%") + print(f" Requests/second: {requests_per_second:.0f}") + print(f" Total time: {elapsed_time:.2f}s") + + # Should maintain high success rate under load + assert success_rate > 95.0, f"Success rate only {success_rate:.1f}%" + + # Should handle reasonable throughput + # Note: This is limited by bcrypt hashing speed + assert requests_per_second > 5.0, f"Only {requests_per_second:.1f} req/s" diff --git a/tests/app/services/auth/test_security.py b/tests/app/services/auth/test_security.py new file mode 100644 index 0000000000..4864352a01 --- /dev/null +++ b/tests/app/services/auth/test_security.py @@ -0,0 +1,459 @@ +"""Security tests for multiuser authentication system. + +This module tests various security aspects including: +- SQL injection prevention +- Authorization bypass attempts +- Session security +- Input validation +""" + +import os +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.users.users_common import UserCreateRequest + + +@pytest.fixture(autouse=True, scope="module") +def client(invokeai_root_dir: Path) -> TestClient: + """Create a test client for the FastAPI app.""" + os.environ["INVOKEAI_ROOT"] = invokeai_root_dir.as_posix() + return TestClient(app) + + +@pytest.fixture(autouse=True) +def enable_multiuser_for_auth_tests(mock_invoker: Invoker) -> None: + """Enable multiuser mode for auth tests. + + Auth tests need multiuser mode enabled since the login/setup endpoints + return 403 when multiuser is disabled. + """ + mock_invoker.services.configuration.multiuser = True + + +class MockApiDependencies(ApiDependencies): + """Mock API dependencies for testing.""" + + invoker: Invoker + + def __init__(self, invoker) -> None: + self.invoker = invoker + + +def setup_test_user(mock_invoker: Invoker, email: str = "test@example.com", password: str = "TestPass123") -> str: + """Helper to create a test user and return user_id.""" + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name="Test User", + password=password, + is_admin=False, + ) + user = user_service.create(user_data) + return user.user_id + + +def setup_test_admin(mock_invoker: Invoker, email: str = "admin@example.com", password: str = "AdminPass123") -> str: + """Helper to create a test admin user and return user_id.""" + user_service = mock_invoker.services.users + user_data = UserCreateRequest( + email=email, + display_name="Admin User", + password=password, + is_admin=True, + ) + user = user_service.create(user_data) + return user.user_id + + +class TestSQLInjectionPrevention: + """Tests to ensure SQL injection attacks are prevented.""" + + def test_login_sql_injection_in_email(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that SQL injection in email field is prevented.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create a legitimate user first + setup_test_user(mock_invoker, "legitimate@example.com", "TestPass123") + + # Try SQL injection in email field + sql_injection_attempts = [ + "' OR '1'='1", + "admin' --", + "' OR 1=1 --", + "'; DROP TABLE users; --", + "' UNION SELECT * FROM users --", + ] + + for injection_attempt in sql_injection_attempts: + response = client.post( + "/api/v1/auth/login", + json={ + "email": injection_attempt, + "password": "TestPass123", + "remember_me": False, + }, + ) + + # Should return 401 (invalid credentials) or 422 (validation error) + # Both are acceptable - the important thing is no SQL injection occurs + assert response.status_code in [401, 422], f"SQL injection attempt should be rejected: {injection_attempt}" + # Should NOT return 200 (success) or 500 (server error) + assert response.status_code != 200, f"SQL injection should not succeed: {injection_attempt}" + assert response.status_code != 500, f"SQL injection should not cause server error: {injection_attempt}" + + def test_login_sql_injection_in_password(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that SQL injection in password field is prevented.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create a legitimate user + setup_test_user(mock_invoker, "test@example.com", "TestPass123") + + # Try SQL injection in password field + sql_injection_attempts = [ + "' OR '1'='1", + "anything' OR '1'='1' --", + "' OR 1=1; DROP TABLE users; --", + ] + + for injection_attempt in sql_injection_attempts: + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test@example.com", + "password": injection_attempt, + "remember_me": False, + }, + ) + + # Should fail authentication + assert response.status_code == 401, f"SQL injection attempt should be rejected: {injection_attempt}" + + def test_user_service_sql_injection_in_email(self, mock_invoker: Invoker): + """Test that user service prevents SQL injection in email lookups.""" + user_service = mock_invoker.services.users + + # Create a test user + setup_test_user(mock_invoker, "test@example.com", "TestPass123") + + # Try SQL injection in get_by_email + sql_injection_attempts = [ + "test@example.com' OR '1'='1", + "' OR 1=1 --", + "test@example.com'; DROP TABLE users; --", + ] + + for injection_attempt in sql_injection_attempts: + # Should return None (not found), not raise an error or return wrong user + user = user_service.get_by_email(injection_attempt) + assert user is None, f"SQL injection should not return a user: {injection_attempt}" + + +class TestAuthorizationBypass: + """Tests to ensure authorization cannot be bypassed.""" + + def test_cannot_access_protected_endpoint_without_token(self, client: TestClient): + """Test that protected endpoints require authentication.""" + # Try to access protected endpoint without token + response = client.get("/api/v1/auth/me") + + assert response.status_code == 401 + + def test_cannot_access_protected_endpoint_with_invalid_token( + self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient + ): + """Test that invalid tokens are rejected.""" + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + invalid_tokens = [ + "invalid_token", + "Bearer invalid_token", + "", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature", + ] + + for token in invalid_tokens: + response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 401, f"Invalid token should be rejected: {token}" + + def test_cannot_forge_admin_token(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that admin privileges cannot be forged by modifying tokens.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create a regular user and login + setup_test_user(mock_invoker, "regular@example.com", "TestPass123") + + login_response = client.post( + "/api/v1/auth/login", + json={ + "email": "regular@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + + token = login_response.json()["token"] + + # Try to modify the token to gain admin privileges + # (In practice, this should fail signature verification) + parts = token.split(".") + if len(parts) == 3: + # Decode the payload, modify it, and re-encode + import base64 + import json + + # Add padding if necessary + payload_b64 = parts[1] + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += "=" * padding + + # Decode payload + try: + payload_bytes = base64.urlsafe_b64decode(payload_b64) + payload_data = json.loads(payload_bytes) + + # Modify is_admin to true + payload_data["is_admin"] = True + + # Re-encode + modified_payload_bytes = json.dumps(payload_data).encode() + modified_payload_b64 = base64.urlsafe_b64encode(modified_payload_bytes).decode().rstrip("=") + + # Create forged token with modified payload but original signature + modified_token = f"{parts[0]}.{modified_payload_b64}.{parts[2]}" + + # Attempt to use modified token + response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {modified_token}"}) + + # Should be rejected (invalid signature) + assert response.status_code == 401 + except Exception: + # If we can't decode/modify the token, that's fine - just skip this part of the test + pass + + def test_regular_user_cannot_create_admin(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that regular users cannot create admin users.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + # This test would require user management endpoints to be implemented + # For now, we test at the service level + user_service = mock_invoker.services.users + + # Create a regular user + regular_user_data = UserCreateRequest( + email="regular@example.com", + display_name="Regular User", + password="TestPass123", + is_admin=False, + ) + user_service.create(regular_user_data) + + # Try to create an admin user (should only be possible through setup or by existing admin) + # The create_admin method checks if an admin already exists + admin_data = UserCreateRequest( + email="sneaky@example.com", + display_name="Sneaky Admin", + password="TestPass123", + ) + + # First create an actual admin + setup_test_admin(mock_invoker, "realadmin@example.com", "AdminPass123") + + # Now trying to create another admin should fail + with pytest.raises(ValueError, match="already exists"): + user_service.create_admin(admin_data) + + +class TestSessionSecurity: + """Tests for session and token security.""" + + def test_token_expires_after_time(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that tokens expire after their validity period.""" + from datetime import timedelta + + from invokeai.app.services.auth.token_service import TokenData, create_access_token + + # Create a token that expires quickly + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + # Create token with 10 millisecond expiration + expired_token = create_access_token(token_data, expires_delta=timedelta(milliseconds=10)) + + # Wait for expiration (wait longer than expiration time) + import time + + time.sleep(0.02) + + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Try to use expired token + response = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {expired_token}"}) + + assert response.status_code == 401 + + def test_logout_invalidates_session(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that logout invalidates the session. + + Note: Current implementation uses JWT which is stateless. + This test documents expected behavior for future server-side session tracking. + """ + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Create user and login + setup_test_user(mock_invoker, "test@example.com", "TestPass123") + + login_response = client.post( + "/api/v1/auth/login", + json={ + "email": "test@example.com", + "password": "TestPass123", + "remember_me": False, + }, + ) + + token = login_response.json()["token"] + + # Logout + logout_response = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}) + + assert logout_response.status_code == 200 + + # Note: With JWT, the token is still technically valid until expiration + # For true session invalidation, server-side session tracking would be needed + + +class TestInputValidation: + """Tests for input validation and sanitization.""" + + def test_email_validation_on_login(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that email validation is enforced on login.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + # Invalid email formats should be rejected by pydantic validation + invalid_emails = [ + "not_an_email", + "@example.com", + "user@", + "user @example.com", # space in email + "../../../etc/passwd", # path traversal attempt + ] + + for invalid_email in invalid_emails: + response = client.post( + "/api/v1/auth/login", + json={ + "email": invalid_email, + "password": "TestPass123", + "remember_me": False, + }, + ) + + # Should return 422 (validation error) or 401 (invalid credentials) + assert response.status_code in [401, 422], f"Invalid email should be rejected: {invalid_email}" + + def test_xss_prevention_in_user_data(self, mock_invoker: Invoker): + """Test that XSS attempts in user data are handled safely. + + Note: Database storage uses parameterized queries which prevent XSS. + This test ensures data is stored and retrieved without executing scripts. + """ + user_service = mock_invoker.services.users + + # Try to create user with XSS payload in display name + xss_payloads = [ + "", + "'; alert('xss'); //", + "", + ] + + for payload in xss_payloads: + user_data = UserCreateRequest( + email=f"xss{hash(payload)}@example.com", # unique email + display_name=payload, + password="TestPass123", + is_admin=False, + ) + + # Should not raise an error - data is stored as-is + user = user_service.create(user_data) + + # Verify data is stored exactly as provided (not executed or modified) + assert user.display_name == payload + + # Cleanup + user_service.delete(user.user_id) + + def test_path_traversal_prevention(self, mock_invoker: Invoker): + """Test that path traversal attempts in user input are handled.""" + user_service = mock_invoker.services.users + + # Path traversal attempts + path_traversal_attempts = [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32", + "user/../../../secret", + ] + + for attempt in path_traversal_attempts: + # These should be stored as literal strings, not interpreted as paths + user_data = UserCreateRequest( + email=f"path{hash(attempt)}@example.com", + display_name=attempt, + password="TestPass123", + is_admin=False, + ) + + user = user_service.create(user_data) + assert user.display_name == attempt + + # Cleanup + user_service.delete(user.user_id) + + +class TestRateLimiting: + """Tests for rate limiting and brute force protection. + + Note: Rate limiting is not currently implemented in the codebase. + These tests document expected behavior for future implementation. + """ + + @pytest.mark.skip(reason="Rate limiting not yet implemented") + def test_login_rate_limiting(self, monkeypatch: Any, mock_invoker: Invoker, client: TestClient): + """Test that excessive login attempts are rate limited.""" + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", MockApiDependencies(mock_invoker)) + + setup_test_user(mock_invoker, "test@example.com", "TestPass123") + + # Try many login attempts with wrong password + for i in range(20): + response = client.post( + "/api/v1/auth/login", + json={ + "email": "test@example.com", + "password": "WrongPassword", + "remember_me": False, + }, + ) + + if i < 10: + # First attempts should return 401 + assert response.status_code == 401 + else: + # After many attempts, should be rate limited (429) + # This is expected behavior for future implementation + pass diff --git a/tests/app/services/auth/test_token_service.py b/tests/app/services/auth/test_token_service.py new file mode 100644 index 0000000000..907da1ae7e --- /dev/null +++ b/tests/app/services/auth/test_token_service.py @@ -0,0 +1,371 @@ +"""Unit tests for JWT token service.""" + +import time +from datetime import timedelta + +import pytest + +from invokeai.app.services.auth.token_service import TokenData, create_access_token, set_jwt_secret, verify_token + + +@pytest.fixture(scope="module", autouse=True) +def setup_jwt_secret(): + """Set up JWT secret for all tests in this module.""" + # Use a test secret key + set_jwt_secret("test-secret-key-for-unit-tests-only-do-not-use-in-production") + + +# Minimum token length to safely modify middle characters for testing +# JWT tokens have format header.payload.signature and are typically >180 characters +MIN_TOKEN_LENGTH_FOR_MODIFICATION = 50 + +# Minimum signature length to safely modify middle characters for testing +# JWT signatures are typically 43 characters (base64-encoded HMAC-SHA256) +MIN_SIGNATURE_LENGTH_FOR_MODIFICATION = 10 + + +class TestTokenCreation: + """Tests for JWT token creation.""" + + def test_create_access_token_basic(self): + """Test creating a basic access token.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + + assert token is not None + assert isinstance(token, str) + assert len(token) > 0 + + def test_create_access_token_with_expiration(self): + """Test creating token with custom expiration.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data, expires_delta=timedelta(hours=1)) + + assert token is not None + # Verify token is valid + verified_data = verify_token(token) + assert verified_data is not None + assert verified_data.user_id == "user123" + + def test_create_access_token_admin_user(self): + """Test creating token for admin user.""" + token_data = TokenData( + user_id="admin123", + email="admin@example.com", + is_admin=True, + ) + + token = create_access_token(token_data) + verified_data = verify_token(token) + + assert verified_data is not None + assert verified_data.is_admin is True + + def test_create_access_token_preserves_all_data(self): + """Test that all token data is preserved.""" + token_data = TokenData( + user_id="user_with_complex_id_12345", + email="complex.email+tag@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + verified_data = verify_token(token) + + assert verified_data is not None + assert verified_data.user_id == token_data.user_id + assert verified_data.email == token_data.email + assert verified_data.is_admin == token_data.is_admin + + def test_create_access_token_different_each_time(self): + """Test that creating token with same data produces different tokens (due to timestamps).""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + # Create tokens with different expiration times to ensure uniqueness + token1 = create_access_token(token_data, expires_delta=timedelta(hours=1)) + token2 = create_access_token(token_data, expires_delta=timedelta(hours=2)) + + # Tokens should be different due to different exp timestamps + assert token1 != token2 + + +class TestTokenVerification: + """Tests for JWT token verification.""" + + def test_verify_valid_token(self): + """Test verifying a valid token.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + verified_data = verify_token(token) + + assert verified_data is not None + assert verified_data.user_id == "user123" + assert verified_data.email == "test@example.com" + assert verified_data.is_admin is False + + def test_verify_invalid_token(self): + """Test verifying an invalid token.""" + verified_data = verify_token("invalid_token_string") + + assert verified_data is None + + def test_verify_malformed_token(self): + """Test verifying malformed tokens.""" + malformed_tokens = [ + "", + "not.a.token", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid", + "header.payload", # Missing signature + ] + + for token in malformed_tokens: + verified_data = verify_token(token) + assert verified_data is None, f"Should reject malformed token: {token}" + + def test_verify_expired_token(self): + """Test verifying an expired token.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + # Create token that expires in 100 milliseconds (0.1 seconds) + token = create_access_token(token_data, expires_delta=timedelta(milliseconds=100)) + + # Wait for token to expire (wait longer than expiration - 200ms to be safe) + time.sleep(0.2) + + # Token should be invalid now + verified_data = verify_token(token) + assert verified_data is None + + def test_verify_token_with_modified_payload(self): + """Test that tokens with modified payloads are rejected.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + + # Try to modify the token by changing a character in the middle + # JWT tokens are base64 encoded, so changing any character should invalidate the signature + # Note: We change a character in the middle to avoid Base64 padding issues where + # the last character might not affect the decoded value + if len(token) > MIN_TOKEN_LENGTH_FOR_MODIFICATION: + mid = len(token) // 2 + modified_token = token[:mid] + ("X" if token[mid] != "X" else "Y") + token[mid + 1 :] + verified_data = verify_token(modified_token) + assert verified_data is None + + def test_verify_token_preserves_admin_status(self): + """Test that admin status is correctly preserved through token lifecycle.""" + # Test with regular user + token_data = TokenData( + user_id="user123", + email="user@example.com", + is_admin=False, + ) + token = create_access_token(token_data) + verified = verify_token(token) + assert verified is not None + assert verified.is_admin is False + + # Test with admin user + admin_token_data = TokenData( + user_id="admin123", + email="admin@example.com", + is_admin=True, + ) + admin_token = create_access_token(admin_token_data) + admin_verified = verify_token(admin_token) + assert admin_verified is not None + assert admin_verified.is_admin is True + + +class TestTokenExpiration: + """Tests for token expiration handling.""" + + def test_token_not_expired_immediately(self): + """Test that freshly created token is not expired.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data, expires_delta=timedelta(hours=1)) + verified_data = verify_token(token) + + assert verified_data is not None + + def test_token_with_long_expiration(self): + """Test token with long expiration time.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + # Create token that expires in 7 days + token = create_access_token(token_data, expires_delta=timedelta(days=7)) + verified_data = verify_token(token) + + assert verified_data is not None + assert verified_data.user_id == "user123" + + def test_token_with_short_expiration_not_expired(self): + """Test token with short but not yet expired time.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + # Create token that expires in 1 second + token = create_access_token(token_data, expires_delta=timedelta(seconds=1)) + + # Immediately verify - should still be valid + verified_data = verify_token(token) + assert verified_data is not None + + +class TestTokenDataModel: + """Tests for TokenData model.""" + + def test_token_data_creation(self): + """Test creating TokenData instance.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + assert token_data.user_id == "user123" + assert token_data.email == "test@example.com" + assert token_data.is_admin is False + + def test_token_data_with_admin(self): + """Test TokenData for admin user.""" + token_data = TokenData( + user_id="admin123", + email="admin@example.com", + is_admin=True, + ) + + assert token_data.is_admin is True + + def test_token_data_model_dump(self): + """Test that TokenData can be serialized.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + data_dict = token_data.model_dump() + + assert isinstance(data_dict, dict) + assert data_dict["user_id"] == "user123" + assert data_dict["email"] == "test@example.com" + assert data_dict["is_admin"] is False + + +class TestTokenSecurity: + """Tests for token security properties.""" + + def test_token_signature_verification(self): + """Test that token signature is verified.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + + # Token should verify correctly + assert verify_token(token) is not None + + # Modified token should fail verification + if len(token) > MIN_TOKEN_LENGTH_FOR_MODIFICATION: + # Change a character in the signature part (last part of JWT) + parts = token.split(".") + if len(parts) == 3 and len(parts[2]) > MIN_SIGNATURE_LENGTH_FOR_MODIFICATION: + # Modify a character in the middle of the signature to avoid Base64 padding issues + # where the last few characters might not affect the decoded value + mid = len(parts[2]) // 2 + modified_signature = parts[2][:mid] + ("X" if parts[2][mid] != "X" else "Y") + parts[2][mid + 1 :] + modified_token = f"{parts[0]}.{parts[1]}.{modified_signature}" + assert verify_token(modified_token) is None + + def test_cannot_forge_admin_token(self): + """Test that admin status cannot be forged by modifying token.""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + + # Any modification to the token should invalidate it + # This prevents attackers from changing is_admin=false to is_admin=true + parts = token.split(".") + if len(parts) == 3: + # Try to modify the payload + modified_payload = parts[1][:-1] + ("X" if parts[1][-1] != "X" else "Y") + modified_token = f"{parts[0]}.{modified_payload}.{parts[2]}" + + verified_data = verify_token(modified_token) + # Modified token should be rejected + assert verified_data is None + + def test_token_uses_strong_algorithm(self): + """Test that token uses secure algorithm (HS256).""" + token_data = TokenData( + user_id="user123", + email="test@example.com", + is_admin=False, + ) + + token = create_access_token(token_data) + + # JWT tokens have format: header.payload.signature + # Header contains algorithm information + import base64 + import json + + parts = token.split(".") + if len(parts) >= 1: + # Decode header (add padding if necessary) + header_b64 = parts[0] + # Add padding if necessary + padding = 4 - len(header_b64) % 4 + if padding != 4: + header_b64 += "=" * padding + + header = json.loads(base64.urlsafe_b64decode(header_b64)) + # Should use HS256 algorithm + assert header.get("alg") == "HS256" diff --git a/tests/app/services/bulk_download/test_bulk_download.py b/tests/app/services/bulk_download/test_bulk_download.py index 223ecc8863..b568c108ef 100644 --- a/tests/app/services/bulk_download/test_bulk_download.py +++ b/tests/app/services/bulk_download/test_bulk_download.py @@ -127,7 +127,12 @@ def test_generate_id_with_board_id(monkeypatch: Any, mock_invoker: Invoker): def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + user_id="test_user", + created_at="None", + updated_at="None", + archived=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get) @@ -156,7 +161,12 @@ def test_handler_board_id(tmp_path: Path, monkeypatch: Any, mock_image_dto: Imag def mock_board_get(*args, **kwargs): return BoardRecord( - board_id="12345", board_name="test_board_name", created_at="None", updated_at="None", archived=False + board_id="12345", + board_name="test_board_name", + user_id="test_user", + created_at="None", + updated_at="None", + archived=False, ) monkeypatch.setattr(mock_invoker.services.board_records, "get", mock_board_get) diff --git a/tests/app/services/session_queue/test_session_queue_clear.py b/tests/app/services/session_queue/test_session_queue_clear.py new file mode 100644 index 0000000000..0c011769ce --- /dev/null +++ b/tests/app/services/session_queue/test_session_queue_clear.py @@ -0,0 +1,106 @@ +"""Tests for session queue clear() user_id scoping.""" + +import uuid + +import pytest + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue + + +@pytest.fixture +def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue: + """Create a SqliteSessionQueue backed by the mock invoker's in-memory database.""" + db = mock_invoker.services.board_records._db + queue = SqliteSessionQueue(db=db) + queue.start(mock_invoker) + return queue + + +def _insert_queue_item(session_queue: SqliteSessionQueue, queue_id: str, user_id: str) -> None: + """Directly insert a minimal queue item for the given user.""" + session_id = str(uuid.uuid4()) + batch_id = str(uuid.uuid4()) + with session_queue._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination, retried_from_item_id, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (queue_id, "{}", session_id, batch_id, None, 0, None, None, None, None, user_id), + ) + + +def _count_items(session_queue: SqliteSessionQueue, queue_id: str, user_id: str | None = None) -> int: + """Count items in the queue, optionally filtered by user_id.""" + with session_queue._db.transaction() as cursor: + if user_id is not None: + cursor.execute( + "SELECT COUNT(*) FROM session_queue WHERE queue_id = ? AND user_id = ?", + (queue_id, user_id), + ) + else: + cursor.execute( + "SELECT COUNT(*) FROM session_queue WHERE queue_id = ?", + (queue_id,), + ) + return cursor.fetchone()[0] + + +def test_clear_with_user_id_only_deletes_own_items(session_queue: SqliteSessionQueue) -> None: + """Non-admin clear (user_id provided) should only remove that user's items.""" + queue_id = "default" + user_a = "user_a" + user_b = "user_b" + + _insert_queue_item(session_queue, queue_id, user_a) + _insert_queue_item(session_queue, queue_id, user_a) + _insert_queue_item(session_queue, queue_id, user_b) + + result = session_queue.clear(queue_id, user_id=user_a) + + assert result.deleted == 2 + assert _count_items(session_queue, queue_id, user_a) == 0 + assert _count_items(session_queue, queue_id, user_b) == 1 + + +def test_clear_without_user_id_deletes_all_items(session_queue: SqliteSessionQueue) -> None: + """Admin clear (no user_id) should remove all items in the queue.""" + queue_id = "default" + + _insert_queue_item(session_queue, queue_id, "user_a") + _insert_queue_item(session_queue, queue_id, "user_b") + _insert_queue_item(session_queue, queue_id, "user_c") + + result = session_queue.clear(queue_id) + + assert result.deleted == 3 + assert _count_items(session_queue, queue_id) == 0 + + +def test_clear_with_user_id_does_not_affect_other_queues(session_queue: SqliteSessionQueue) -> None: + """Clearing one queue should not affect items in another queue.""" + queue_a = "queue_a" + queue_b = "queue_b" + user_id = "user_x" + + _insert_queue_item(session_queue, queue_a, user_id) + _insert_queue_item(session_queue, queue_b, user_id) + + result = session_queue.clear(queue_a, user_id=user_id) + + assert result.deleted == 1 + assert _count_items(session_queue, queue_a) == 0 + assert _count_items(session_queue, queue_b) == 1 + + +def test_clear_returns_zero_when_no_matching_items(session_queue: SqliteSessionQueue) -> None: + """Clear should return 0 deleted when there are no items for the given user.""" + queue_id = "default" + + _insert_queue_item(session_queue, queue_id, "user_b") + + result = session_queue.clear(queue_id, user_id="user_a") + + assert result.deleted == 0 + assert _count_items(session_queue, queue_id) == 1 diff --git a/tests/app/services/users/test_password_utils.py b/tests/app/services/users/test_password_utils.py new file mode 100644 index 0000000000..68fd37db23 --- /dev/null +++ b/tests/app/services/users/test_password_utils.py @@ -0,0 +1,56 @@ +"""Tests for password utilities.""" + +from invokeai.app.services.auth.password_utils import hash_password, validate_password_strength, verify_password + + +def test_hash_password(): + """Test password hashing.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert hashed != password + assert len(hashed) > 0 + + +def test_verify_password(): + """Test password verification.""" + password = "TestPassword123" + hashed = hash_password(password) + + assert verify_password(password, hashed) + assert not verify_password("WrongPassword", hashed) + + +def test_validate_password_strength_valid(): + """Test password strength validation with valid passwords.""" + valid, msg = validate_password_strength("ValidPass123") + assert valid + assert msg == "" + + +def test_validate_password_strength_too_short(): + """Test password strength validation with short password.""" + valid, msg = validate_password_strength("Pass1") + assert not valid + assert "at least 8 characters" in msg + + +def test_validate_password_strength_no_uppercase(): + """Test password strength validation without uppercase.""" + valid, msg = validate_password_strength("password123") + assert not valid + assert "uppercase" in msg.lower() + + +def test_validate_password_strength_no_lowercase(): + """Test password strength validation without lowercase.""" + valid, msg = validate_password_strength("PASSWORD123") + assert not valid + assert "lowercase" in msg.lower() + + +def test_validate_password_strength_no_digit(): + """Test password strength validation without digit.""" + valid, msg = validate_password_strength("PasswordTest") + assert not valid + assert "number" in msg.lower() diff --git a/tests/app/services/users/test_token_service.py b/tests/app/services/users/test_token_service.py new file mode 100644 index 0000000000..3dec800082 --- /dev/null +++ b/tests/app/services/users/test_token_service.py @@ -0,0 +1,43 @@ +"""Tests for token service.""" + +from datetime import timedelta + +from invokeai.app.services.auth.token_service import TokenData, create_access_token, verify_token + + +def test_create_access_token(): + """Test creating an access token.""" + data = TokenData(user_id="test-user", email="test@example.com", is_admin=False) + token = create_access_token(data) + + assert token is not None + assert len(token) > 0 + + +def test_verify_valid_token(): + """Test verifying a valid token.""" + data = TokenData(user_id="test-user", email="test@example.com", is_admin=True) + token = create_access_token(data) + + verified_data = verify_token(token) + + assert verified_data is not None + assert verified_data.user_id == data.user_id + assert verified_data.email == data.email + assert verified_data.is_admin == data.is_admin + + +def test_verify_invalid_token(): + """Test verifying an invalid token.""" + verified_data = verify_token("invalid-token") + assert verified_data is None + + +def test_token_with_custom_expiration(): + """Test creating token with custom expiration.""" + data = TokenData(user_id="test-user", email="test@example.com", is_admin=False) + token = create_access_token(data, expires_delta=timedelta(hours=1)) + + verified_data = verify_token(token) + assert verified_data is not None + assert verified_data.user_id == data.user_id diff --git a/tests/app/services/users/test_user_service.py b/tests/app/services/users/test_user_service.py new file mode 100644 index 0000000000..479c911a0d --- /dev/null +++ b/tests/app/services/users/test_user_service.py @@ -0,0 +1,259 @@ +"""Tests for user service.""" + +from logging import Logger + +import pytest + +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.users.users_common import UserCreateRequest, UserUpdateRequest +from invokeai.app.services.users.users_default import UserService + + +@pytest.fixture +def logger() -> Logger: + """Create a logger for testing.""" + return Logger("test_user_service") + + +@pytest.fixture +def db(logger: Logger) -> SqliteDatabase: + """Create an in-memory database for testing.""" + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + # Create users table manually for testing + db._conn.execute(""" + CREATE TABLE users ( + user_id TEXT NOT NULL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + last_login_at DATETIME + ); + """) + db._conn.commit() + return db + + +@pytest.fixture +def user_service(db: SqliteDatabase) -> UserService: + """Create a user service for testing.""" + return UserService(db) + + +def test_create_user(user_service: UserService): + """Test creating a user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + is_admin=False, + ) + + user = user_service.create(user_data) + + assert user.email == "test@example.com" + assert user.display_name == "Test User" + assert user.is_admin is False + assert user.is_active is True + assert user.user_id is not None + + +def test_create_user_weak_password(user_service: UserService): + """Test creating a user with weak password.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="weak", + is_admin=False, + ) + + with pytest.raises(ValueError, match="at least 8 characters"): + user_service.create(user_data) + + +def test_create_duplicate_user(user_service: UserService): + """Test creating a duplicate user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + is_admin=False, + ) + + user_service.create(user_data) + + with pytest.raises(ValueError, match="already exists"): + user_service.create(user_data) + + +def test_get_user(user_service: UserService): + """Test getting a user by ID.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + created_user = user_service.create(user_data) + retrieved_user = user_service.get(created_user.user_id) + + assert retrieved_user is not None + assert retrieved_user.user_id == created_user.user_id + assert retrieved_user.email == created_user.email + + +def test_get_nonexistent_user(user_service: UserService): + """Test getting a nonexistent user.""" + user = user_service.get("nonexistent-id") + assert user is None + + +def test_get_user_by_email(user_service: UserService): + """Test getting a user by email.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + created_user = user_service.create(user_data) + retrieved_user = user_service.get_by_email("test@example.com") + + assert retrieved_user is not None + assert retrieved_user.user_id == created_user.user_id + assert retrieved_user.email == "test@example.com" + + +def test_update_user(user_service: UserService): + """Test updating a user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user = user_service.create(user_data) + + updates = UserUpdateRequest( + display_name="Updated Name", + is_admin=True, + ) + + updated_user = user_service.update(user.user_id, updates) + + assert updated_user.display_name == "Updated Name" + assert updated_user.is_admin is True + + +def test_delete_user(user_service: UserService): + """Test deleting a user.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user = user_service.create(user_data) + user_service.delete(user.user_id) + + retrieved_user = user_service.get(user.user_id) + assert retrieved_user is None + + +def test_authenticate_valid_credentials(user_service: UserService): + """Test authenticating with valid credentials.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user_service.create(user_data) + authenticated_user = user_service.authenticate("test@example.com", "TestPassword123") + + assert authenticated_user is not None + assert authenticated_user.email == "test@example.com" + assert authenticated_user.last_login_at is not None + + +def test_authenticate_invalid_password(user_service: UserService): + """Test authenticating with invalid password.""" + user_data = UserCreateRequest( + email="test@example.com", + display_name="Test User", + password="TestPassword123", + ) + + user_service.create(user_data) + authenticated_user = user_service.authenticate("test@example.com", "WrongPassword") + + assert authenticated_user is None + + +def test_authenticate_nonexistent_user(user_service: UserService): + """Test authenticating nonexistent user.""" + authenticated_user = user_service.authenticate("nonexistent@example.com", "TestPassword123") + assert authenticated_user is None + + +def test_has_admin(user_service: UserService): + """Test checking if admin exists.""" + assert user_service.has_admin() is False + + user_data = UserCreateRequest( + email="admin@example.com", + display_name="Admin User", + password="AdminPassword123", + is_admin=True, + ) + + user_service.create(user_data) + assert user_service.has_admin() is True + + +def test_create_admin(user_service: UserService): + """Test creating an admin user.""" + user_data = UserCreateRequest( + email="admin@example.com", + display_name="Admin User", + password="AdminPassword123", + ) + + admin = user_service.create_admin(user_data) + + assert admin.is_admin is True + assert admin.email == "admin@example.com" + + +def test_create_admin_when_exists(user_service: UserService): + """Test creating admin when one already exists.""" + user_data = UserCreateRequest( + email="admin@example.com", + display_name="Admin User", + password="AdminPassword123", + ) + + user_service.create_admin(user_data) + + with pytest.raises(ValueError, match="already exists"): + user_service.create_admin(user_data) + + +def test_list_users(user_service: UserService): + """Test listing users.""" + for i in range(5): + user_data = UserCreateRequest( + email=f"test{i}@example.com", + display_name=f"Test User {i}", + password="TestPassword123", + ) + user_service.create(user_data) + + users = user_service.list_users() + assert len(users) == 5 + + limited_users = user_service.list_users(limit=2) + assert len(limited_users) == 2 diff --git a/tests/conftest.py b/tests/conftest.py index d2835120e9..980a99611a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,13 +12,17 @@ import pytest 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.config.config_default import InvokeAIAppConfig +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_services import InvocationServices from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService from invokeai.app.services.invoker import Invoker +from invokeai.app.services.users.users_default import UserService from invokeai.backend.util.logging import InvokeAILogger from tests.backend.model_manager.model_manager_fixtures import * # noqa: F403 from tests.fixtures.sqlite_database import create_mock_sqlite_database # noqa: F401 @@ -36,12 +40,12 @@ def mock_services() -> InvocationServices: board_image_records=SqliteBoardImageRecordStorage(db=db), board_images=None, # type: ignore board_records=SqliteBoardRecordStorage(db=db), - boards=None, # type: ignore + boards=BoardService(), bulk_download=BulkDownloadService(), configuration=configuration, events=TestEventService(), image_files=None, # type: ignore - image_records=None, # type: ignore + image_records=SqliteImageRecordStorage(db=db), images=ImageService(), invocation_cache=MemoryInvocationCache(max_cache_size=0), logger=logging, # type: ignore @@ -61,7 +65,8 @@ def mock_services() -> InvocationServices: workflow_thumbnails=None, # type: ignore model_relationship_records=None, # type: ignore model_relationships=None, # type: ignore - client_state_persistence=None, # type: ignore + client_state_persistence=ClientStatePersistenceSqlite(db=db), + users=UserService(db), ) diff --git a/tests/test_sqlite_migrator.py b/tests/test_sqlite_migrator.py index f6a3cb2a5a..e03224b5a9 100644 --- a/tests/test_sqlite_migrator.py +++ b/tests/test_sqlite_migrator.py @@ -296,3 +296,175 @@ def test_idempotent_migrations(migrator: SqliteMigrator, migration_create_test_t # not throwing is sufficient migrator.run_migrations() assert migrator._get_current_version(cursor) == 1 + + +def test_migration_27_creates_users_table(logger: Logger) -> None: + """Test that migration 27 creates the users table and related tables.""" + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import Migration27Callback + + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + cursor = db._conn.cursor() + + # Create minimal tables that migration 27 expects to exist + cursor.execute("CREATE TABLE IF NOT EXISTS boards (board_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS images (image_name TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS workflows (workflow_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS session_queue (item_id INTEGER PRIMARY KEY);") + db._conn.commit() + + # Run migration callback directly (not through migrator to avoid chain validation) + migration_callback = Migration27Callback() + migration_callback(cursor) + db._conn.commit() + + # Verify users table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users';") + assert cursor.fetchone() is not None + + # Verify user_sessions table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_sessions';") + assert cursor.fetchone() is not None + + # Verify user_invitations table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_invitations';") + assert cursor.fetchone() is not None + + # Verify shared_boards table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='shared_boards';") + assert cursor.fetchone() is not None + + # Verify system user was created + cursor.execute("SELECT user_id, email FROM users WHERE user_id='system';") + system_user = cursor.fetchone() + assert system_user is not None + assert system_user[0] == "system" + assert system_user[1] == "system@system.invokeai" + + # Verify boards table has user_id column + cursor.execute("PRAGMA table_info(boards);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "is_public" in columns + + # Verify images table has user_id column + cursor.execute("PRAGMA table_info(images);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + + # Verify workflows table has user_id and is_public columns + cursor.execute("PRAGMA table_info(workflows);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "is_public" in columns + + # Verify client_state table has the new per-user schema + cursor.execute("PRAGMA table_info(client_state);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "key" in columns + assert "value" in columns + + # Verify app_settings table exists and contains a JWT secret + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings';") + assert cursor.fetchone() is not None + cursor.execute("SELECT value FROM app_settings WHERE key = 'jwt_secret';") + jwt_row = cursor.fetchone() + assert jwt_row is not None + assert len(jwt_row[0]) == 64 # 32 bytes = 64 hex characters + + db._conn.close() + + +def test_migration_27_with_existing_client_state_data(logger: Logger) -> None: + """Test that migration 27 correctly migrates existing data from the old client_state schema.""" + import json + + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_21 import Migration21Callback + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import Migration27Callback + + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + cursor = db._conn.cursor() + + # Run migration 21 to create old-style client_state with data column + Migration21Callback()(cursor) + # Insert some test data + cursor.execute( + "INSERT INTO client_state (id, data) VALUES (1, ?);", + (json.dumps({"galleryView": "images", "lastBoardId": "board123"}),), + ) + db._conn.commit() + + # Run migration 27 pre-reqs + cursor.execute("CREATE TABLE IF NOT EXISTS boards (board_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS images (image_name TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS workflows (workflow_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS session_queue (item_id INTEGER PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS style_presets (id TEXT PRIMARY KEY);") + db._conn.commit() + + # Run migration 27 + Migration27Callback()(cursor) + db._conn.commit() + + # Verify new client_state schema + cursor.execute("PRAGMA table_info(client_state);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "key" in columns + assert "value" in columns + assert "updated_at" in columns + assert "data" not in columns + + # Verify data was migrated to 'system' user + cursor.execute("SELECT user_id, key, value FROM client_state ORDER BY key;") + rows = [tuple(row) for row in cursor.fetchall()] + assert len(rows) == 2 + assert ("system", "galleryView", "images") in rows + assert ("system", "lastBoardId", "board123") in rows + + db._conn.close() + + +def test_migration_27_without_client_state_data_column(logger: Logger) -> None: + """Test that migration 27 handles old client_state table without the data column.""" + from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import Migration27Callback + + db = SqliteDatabase(db_path=None, logger=logger, verbose=False) + cursor = db._conn.cursor() + + # Create old client_state WITHOUT data column (simulating an older migration 21) + cursor.execute( + """ + CREATE TABLE client_state ( + id INTEGER PRIMARY KEY CHECK(id = 1), + updated_at DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ); + """ + ) + db._conn.commit() + + # Run migration 27 pre-reqs + cursor.execute("CREATE TABLE IF NOT EXISTS boards (board_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS images (image_name TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS workflows (workflow_id TEXT PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS session_queue (item_id INTEGER PRIMARY KEY);") + cursor.execute("CREATE TABLE IF NOT EXISTS style_presets (id TEXT PRIMARY KEY);") + db._conn.commit() + + # Run migration 27 - should not raise even without data column + Migration27Callback()(cursor) + db._conn.commit() + + # Verify new client_state schema + cursor.execute("PRAGMA table_info(client_state);") + columns = [row[1] for row in cursor.fetchall()] + assert "user_id" in columns + assert "key" in columns + assert "value" in columns + assert "updated_at" in columns + + # No rows should be migrated (nothing to migrate) + cursor.execute("SELECT COUNT(*) FROM client_state;") + assert cursor.fetchone()[0] == 0 + + db._conn.close() diff --git a/uv.lock b/uv.lock index f6841cb6e7..a22015f28f 100644 --- a/uv.lock +++ b/uv.lock @@ -152,6 +152,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "bcrypt" +version = "3.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/36/edc85ab295ceff724506252b774155eff8a238f13730c8b13badd33ef866/bcrypt-3.2.2.tar.gz", hash = "sha256:433c410c2177057705da2a9f2cd01dd157493b2a7ac14c8593a16b3dab6b6bfb", size = 42455, upload-time = "2022-05-01T17:58:52.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c2/05354b1d4351d2e686a32296cc9dd1e63f9909a580636df0f7b06d774600/bcrypt-3.2.2-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:7180d98a96f00b1050e93f5b0f556e658605dd9f524d0b0e68ae7944673f525e", size = 50049, upload-time = "2022-05-01T18:05:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b3/1257f7d64ee0aa0eb4fb1de5da8c2647a57db7b737da1f2342ac1889d3b8/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:61bae49580dce88095d669226d5076d0b9d927754cedbdf76c6c9f5099ad6f26", size = 54914, upload-time = "2022-05-01T18:03:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/61/3d/dce83194830183aa700cab07c89822471d21663a86a0b305d1e5c7b02810/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88273d806ab3a50d06bc6a2fc7c87d737dd669b76ad955f449c43095389bc8fb", size = 54403, upload-time = "2022-05-01T18:03:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/86/1b/f4d7425dfc6cd0e405b48ee484df6d80fb39e05f25963dbfcc2c511e8341/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6d2cb9d969bfca5bc08e45864137276e4c3d3d7de2b162171def3d188bf9d34a", size = 62337, upload-time = "2022-05-01T18:05:49.524Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/289db4f31b303de6addb0897c8b5c01b23bd4b8c511ac80a32b08658847c/bcrypt-3.2.2-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b02d6bfc6336d1094276f3f588aa1225a598e27f8e3388f4db9948cb707b521", size = 61026, upload-time = "2022-05-01T18:05:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/40/8f/b67b42faa2e4d944b145b1a402fc08db0af8fe2dfa92418c674b5a302496/bcrypt-3.2.2-cp36-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a2c46100e315c3a5b90fdc53e429c006c5f962529bc27e1dfd656292c20ccc40", size = 64672, upload-time = "2022-05-01T18:05:52.748Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9a/e1867f0b27a3f4ce90e21dd7f322f0e15d4aac2434d3b938dcf765e47c6b/bcrypt-3.2.2-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7d9ba2e41e330d2af4af6b1b6ec9e6128e91343d0b4afb9282e54e5508f31baa", size = 56795, upload-time = "2022-05-01T18:03:04.028Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/057b0637c880e6cb0abdc8a867d080376ddca6ed7d05b7738f589cc5c1a8/bcrypt-3.2.2-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cd43303d6b8a165c29ec6756afd169faba9396a9472cdff753fe9f19b96ce2fa", size = 62075, upload-time = "2022-05-01T18:05:54.412Z" }, + { url = "https://files.pythonhosted.org/packages/f1/64/cd93e2c3e28a5fa8bcf6753d5cc5e858e4da08bf51404a0adb6a412532de/bcrypt-3.2.2-cp36-abi3-win32.whl", hash = "sha256:4e029cef560967fb0cf4a802bcf4d562d3d6b4b1bf81de5ec1abbe0f1adb027e", size = 27916, upload-time = "2022-05-01T18:05:56.45Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/7cd297ff571c4d86371ff024c0e008b37b59e895b28f69444a9b6f94ca1a/bcrypt-3.2.2-cp36-abi3-win_amd64.whl", hash = "sha256:7ff2069240c6bbe49109fe84ca80508773a904f5a8cb960e02a977f7f519b129", size = 29581, upload-time = "2022-05-01T18:05:57.878Z" }, +] + [[package]] name = "bidict" version = "0.23.1" @@ -499,7 +520,7 @@ name = "cryptography" version = "45.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'extra-8-invokeai-cpu') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra == 'extra-8-invokeai-cuda') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and extra != 'extra-8-invokeai-rocm') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (platform_python_implementation == 'PyPy' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (sys_platform == 'darwin' and extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } wheels = [ @@ -624,6 +645,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/f0/dbe05efee6a38fb075ba0995e497223d02c6d056303d5e8881e9bb20652a/dynamicprompts-0.31.0-py3-none-any.whl", hash = "sha256:a07f38c295ec2b77905cecba8b0f439bb1a84942bfb6874ff6b55448e2cc950e", size = 53524, upload-time = "2024-03-21T07:58:36.994Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + [[package]] name = "einops" version = "0.8.1" @@ -633,6 +666,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "faker" version = "37.4.0" @@ -961,6 +1007,7 @@ name = "invokeai" source = { editable = "." } dependencies = [ { name = "accelerate" }, + { name = "bcrypt" }, { name = "bitsandbytes", marker = "sys_platform != 'darwin' or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-cuda') or (extra == 'extra-8-invokeai-cpu' and extra == 'extra-8-invokeai-rocm') or (extra == 'extra-8-invokeai-cuda' and extra == 'extra-8-invokeai-rocm')" }, { name = "blake3" }, { name = "compel" }, @@ -969,6 +1016,7 @@ dependencies = [ { name = "dnspython" }, { name = "dynamicprompts" }, { name = "einops" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "fastapi-events" }, { name = "gguf" }, @@ -978,12 +1026,14 @@ dependencies = [ { name = "onnx" }, { name = "onnxruntime" }, { name = "opencv-contrib-python" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "picklescan" }, { name = "pillow" }, { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pypatchmatch" }, + { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "python-socketio" }, { name = "pywavelets" }, @@ -1067,6 +1117,7 @@ xformers = [ [package.metadata] requires-dist = [ { name = "accelerate" }, + { name = "bcrypt", specifier = "<4.0.0" }, { name = "bitsandbytes", marker = "sys_platform != 'darwin'" }, { name = "blake3" }, { name = "compel", specifier = "==2.1.1" }, @@ -1075,6 +1126,7 @@ requires-dist = [ { name = "dnspython" }, { name = "dynamicprompts" }, { name = "einops" }, + { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = "==0.118.3" }, { name = "fastapi-events" }, { name = "gguf" }, @@ -1096,6 +1148,7 @@ requires-dist = [ { name = "onnxruntime-directml", marker = "extra == 'onnx-directml'" }, { name = "onnxruntime-gpu", marker = "extra == 'onnx-cuda'" }, { name = "opencv-contrib-python" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "picklescan" }, { name = "pillow" }, { name = "pip-tools", marker = "extra == 'dist'" }, @@ -1111,6 +1164,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytest-datadir", marker = "extra == 'test'" }, { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "python-multipart" }, { name = "python-socketio" }, { name = "pytorch-triton-rocm", marker = "sys_platform == 'linux' and extra == 'rocm'", index = "https://download.pytorch.org/whl/rocm6.3", conflict = { package = "invokeai", extra = "rocm" } }, @@ -2300,6 +2354,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -2498,6 +2566,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/01/069766294390d3e10c77dfb553171466d67ffb51bf72a437650c0a5db86a/pudb-2025.1-py3-none-any.whl", hash = "sha256:f642d42e6054c992b43c463742650aa879fe290d7d7ffdeb21f7d00dc4587a21", size = 89208, upload-time = "2025-05-06T20:43:17.101Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + [[package]] name = "pycparser" version = "2.22" @@ -2747,6 +2824,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/fa/df59acedf7bbb937f69174d00f921a7b93aa5a5f5c17d05296c814fff6fc/python_engineio-4.12.2-py3-none-any.whl", hash = "sha256:8218ab66950e179dfec4b4bbb30aecf3f5d86f5e58e6fc1aa7fde2c698b2804f", size = 59536, upload-time = "2025-06-04T19:22:16.916Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-multipart" version = "0.0.20" @@ -3001,6 +3097,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.11.13" From c83c4af1ea0d9b92b5000badeb94a65a33dc81f9 Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:06:41 +0200 Subject: [PATCH 03/24] Fix(UI): Fixes broken "Cancel Current Item" button in left panel. (#8925) --- .../features/queue/hooks/useCancelCurrentQueueItem.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts index 797a940507..cad50c3deb 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useCancelCurrentQueueItem.ts @@ -2,6 +2,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { selectCurrentUser } from 'features/auth/store/authSlice'; import { useCurrentQueueItemId } from 'features/queue/hooks/useCurrentQueueItemId'; import { useCallback, useMemo } from 'react'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; import { useGetCurrentQueueItemQuery } from 'services/api/endpoints/queue'; import { useCancelQueueItem } from './useCancelQueueItem'; @@ -10,10 +11,16 @@ export const useCancelCurrentQueueItem = () => { const currentQueueItemId = useCurrentQueueItemId(); const { data: currentQueueItem } = useGetCurrentQueueItemQuery(); const currentUser = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); const cancelQueueItem = useCancelQueueItem(); // Check if current user can cancel the current item const canCancelCurrentItem = useMemo(() => { + // In single-user mode, allow canceling current item without auth checks. + if (setupStatus && !setupStatus.multiuser_enabled) { + return true; + } + if (!currentUser || !currentQueueItem) { return false; } @@ -23,7 +30,7 @@ export const useCancelCurrentQueueItem = () => { } // Non-admin users can only cancel their own items return currentQueueItem.user_id === currentUser.user_id; - }, [currentUser, currentQueueItem]); + }, [setupStatus, currentUser, currentQueueItem]); const trigger = useCallback( (options?: { withToast?: boolean }) => { From 4fd5cd26a04233e4418c9e8c8a4e8ac5e38e1f44 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 28 Feb 2026 16:09:46 +0100 Subject: [PATCH 04/24] ui: translations update from weblate (#8924) * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2205 of 2250 strings) Co-authored-by: Riccardo Giovanetti Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ Translation: InvokeAI/Web UI * translationBot(ui): update translation files Updated by "Remove blank strings" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI * translationBot(ui): update translation (Italian) Currently translated at 97.8% (2210 of 2259 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.8% (2224 of 2272 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 98.1% (2252 of 2295 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2264 of 2309 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Russian) Currently translated at 60.7% (1419 of 2334 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ru/ * translationBot(ui): update translation (Italian) Currently translated at 98.1% (2290 of 2334 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.7% (2319 of 2372 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ --------- Co-authored-by: Riccardo Giovanetti Co-authored-by: DustyShoe --- invokeai/frontend/web/public/locales/it.json | 71 ++++++- invokeai/frontend/web/public/locales/ru.json | 212 ++++++++++++------- 2 files changed, 207 insertions(+), 76 deletions(-) diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index f110b72309..1134725c85 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -822,7 +822,21 @@ "orphanedModelsDeleted": "Eliminato con successo {{count}} modello orfano", "orphanedModelsDeleteErrors": "Alcuni modelli non possono essere eliminati", "orphanedModelsDeleteFailed": "Impossibile eliminare i modelli orfani", - "errorLoadingOrphanedModels": "Errore durante il caricamento dei modelli orfani. Riprova." + "errorLoadingOrphanedModels": "Errore durante il caricamento dei modelli orfani. Riprova.", + "pause": "Pausa", + "pauseAll": "Metti in pausa tutto", + "pauseAllTooltip": "Metti in pausa tutti i download attivi", + "resume": "Riprendi", + "resumeAll": "Riprendi tutto", + "resumeAllTooltip": "Riprendi tutti i download in pausa", + "restartFailed": "Riavvio non riuscito", + "restartFile": "Riavvia il file", + "restartRequired": "Riavvio richiesto", + "resumeRefused": "Ripristino rifiutato dal server. Riavvio richiesto.", + "backendDisconnected": "Backend disconnesso", + "cancelAll": "Annulla tutto", + "cancelAllTooltip": "Annulla tutti i download attivi", + "selectModelToView": "Seleziona un modello per visualizzarne i dettagli" }, "parameters": { "images": "Immagini", @@ -1098,7 +1112,12 @@ "kleinEncoderClearedDescription": "Selezionare un encoder Qwen3 compatibile per la nuova variante del modello Klein", "kleinEncoderCleared": "Encoder Qwen3 cancellato", "schedulerReset": "Ripristino campionatore", - "schedulerResetZImageBase": "Il campionatore LCM non è compatibile con i modelli Z-Image Base. Reimpostare su Euler." + "schedulerResetZImageBase": "Il campionatore LCM non è compatibile con i modelli Z-Image Base. Reimpostare su Euler.", + "modelDownloadPaused": "Download del modello in pausa", + "modelDownloadResumed": "Ripresa del download", + "modelDownloadRestartFailed": "Riavvia i download non riusciti", + "modelDownloadRestartFile": "Riavvio del download del file", + "modelDownloadRestartedFromScratch": "Manca una parte del file. Riavviato il download dall'inizio." }, "accessibility": { "invokeProgressBar": "Barra di avanzamento generazione", @@ -1357,7 +1376,13 @@ "locateInGalery": "Trova nella Galleria", "deletedImagesCannotBeRestored": "Le immagini eliminate non possono essere ripristinate.", "hideBoards": "Nascondi bacheche", - "viewBoards": "Visualizza le bacheche" + "viewBoards": "Visualizza le bacheche", + "pause": "Pausa", + "resume": "Riprendi", + "restartFailed": "Riavvio non riuscito", + "restartFile": "Riavvia il file", + "restartRequired": "Riavvio richiesto", + "resumeRefused": "Ripristino rifiutato dal server. Riavvio richiesto." }, "queue": { "queueFront": "Aggiungi all'inizio della coda", @@ -1449,7 +1474,13 @@ "sortOrderDescending": "Discendente", "createdAt": "Creato", "completedAt": "Completato", - "batchFieldValues": "Valori del campo Lotto" + "batchFieldValues": "Valori del campo Lotto", + "paused": "In pausa", + "cancelFailedAccessDenied": "Problema durante l'annullamento dell'articolo: accesso negato", + "clearFailedAccessDenied": "Problema durante la cancellazione della coda: accesso negato", + "user": "Utente", + "cannotViewDetails": "Non hai l'autorizzazione per visualizzare i dettagli di questo elemento della coda", + "fieldValuesHidden": "" }, "models": { "noMatchingModels": "Nessun modello corrispondente", @@ -2557,7 +2588,8 @@ "isEmpty": "{{title}} è vuoto", "isDisabled": "{{title}} è disabilitato" }, - "scaledBbox": "Riquadro scalato" + "scaledBbox": "Riquadro scalato", + "textSessionActive": "L'inserimento del testo è attivo" }, "canvasContextMenu": { "newControlLayer": "Nuovo Livello di Controllo", @@ -3024,5 +3056,34 @@ }, "lora": { "weight": "Peso" + }, + "auth": { + "login": { + "title": "Accedi a InvokeAI", + "rememberMe": "Ricordami per 7 giorni", + "signIn": "Accedi", + "signingIn": "Accesso in corso...", + "loginFailed": "Accesso non riuscito. Controlla le tue credenziali." + }, + "setup": { + "title": "Benvenuti a InvokeAI", + "subtitle": "Configura il tuo account amministratore per iniziare", + "emailHelper": "Questo sarà il tuo nome utente per accedere", + "displayName": "Nome da visualizzare", + "displayNamePlaceholder": "Amministratore", + "displayNameHelper": "Il tuo nome come apparirà nell'applicazione", + "passwordHelper": "Deve contenere almeno 8 caratteri, tra maiuscole, minuscole e numeri", + "passwordTooShort": "La password deve essere lunga almeno 8 caratteri", + "passwordMissingRequirements": "La password deve contenere maiuscole, minuscole e numeri", + "confirmPassword": "Conferma password", + "confirmPasswordPlaceholder": "Conferma password", + "passwordsDoNotMatch": "Le password non corrispondono", + "createAccount": "Crea un account amministratore", + "creatingAccount": "Impostazione in corso...", + "setupFailed": "Installazione non riuscita. Riprova." + }, + "userMenu": "Menu utente", + "logout": "Esci", + "adminOnlyFeature": "Questa funzionalità è disponibile solo per gli amministratori." } } diff --git a/invokeai/frontend/web/public/locales/ru.json b/invokeai/frontend/web/public/locales/ru.json index 88f25d7159..fd9c0875ce 100644 --- a/invokeai/frontend/web/public/locales/ru.json +++ b/invokeai/frontend/web/public/locales/ru.json @@ -89,7 +89,16 @@ "none": "Ничего", "new": "Новый", "ok": "Ok", - "close": "Закрыть" + "close": "Закрыть", + "error_withCount_one": "{{count}} Ошибка", + "error_withCount_few": "{{count}} Ошибки", + "error_withCount_many": "{{count}} Ошибок", + "model_withCount_one": "{{count}} Модель", + "model_withCount_few": "{{count}} Модели", + "model_withCount_many": "{{count}} Моделей", + "options_withCount_one": "{{count}} Опция", + "options_withCount_few": "{{count}} Опции", + "options_withCount_many": "{{count}} Опций" }, "gallery": { "galleryImageSize": "Размер изображений", @@ -108,7 +117,7 @@ "downloadSelection": "Скачать выделенное", "currentlyInUse": "В настоящее время это изображение используется в следующих функциях:", "unstarImage": "Удалить из избранного", - "dropOrUpload": "$t(gallery.drop) или загрузить", + "dropOrUpload": "Перетащите или загрузите", "copy": "Копировать", "download": "Скачать", "noImageSelected": "Изображение не выбрано", @@ -239,7 +248,7 @@ }, "filterSelected": { "title": "Filter", - "desc": "Filter the selected layer. Only applies to Raster and Control layers." + "desc": "Применяет фильтр к выбранному слою. Применимо только к растровым слоям и слоям управления." }, "undo": { "desc": "Отменяет последнее действие на холсте.", @@ -483,7 +492,7 @@ "deleteMsg1": "Вы точно хотите удалить модель из InvokeAI?", "deleteMsg2": "Это приведет К УДАЛЕНИЮ модели С ДИСКА, если она находится в корневой папке Invoke. Если вы используете пользовательское расположение, то модель НЕ будет удалена с диска.", "convertToDiffusersHelpText5": "Пожалуйста, убедитесь, что у вас достаточно места на диске. Модели обычно занимают 2–7 Гб.", - "convertToDiffusersHelpText3": "Ваш файл контрольной точки НА ДИСКЕ будет УДАЛЕН, если он находится в корневой папке InvokeAI. Если он находится в пользовательском расположении, то он НЕ будет удален.", + "convertToDiffusersHelpText3": "Файл чекпоинта будет удалён с диска, если он находится в корневой папке InvokeAI. Если файл расположен в пользовательской папке, он удалён не будет.", "allModels": "Все модели", "repo_id": "ID репозитория", "convert": "Преобразовать", @@ -541,7 +550,7 @@ "pathToConfig": "Путь к конфигурации", "loraTriggerPhrases": "Триггерные фразы LoRA", "mainModelTriggerPhrases": "Триггерные фразы основной модели", - "inplaceInstallDesc": "Устанавливайте модели без копирования файлов. При использовании модели она будет загружаться из этого места. Если этот параметр отключен, файлы модели будут скопированы в каталог моделей, управляемых Invoke, во время установки.", + "inplaceInstallDesc": "Устанавливать модели без перемещения файлов. В этом случае модель будет загружаться из исходной папки. Если опция отключена, файлы модели при установке будут перемещены в каталог моделей Invoke.", "huggingFaceRepoID": "ID репозитория HuggingFace", "installQueue": "Очередь установки", "installAll": "Установить все", @@ -575,8 +584,8 @@ "skippingXDuplicates_one": ", пропуская {{count}} дубликат", "skippingXDuplicates_few": ", пропуская {{count}} дубликата", "skippingXDuplicates_many": ", пропуская {{count}} дубликатов", - "includesNModels": "Включает в себя {{n}} моделей и их зависимостей", - "starterBundleHelpText": "Легко установите все модели, необходимые для начала работы с базовой моделью, включая основную модель, сети управления, IP-адаптеры и многое другое. При выборе комплекта все уже установленные модели будут пропущены." + "includesNModels": "Включает в себя {{n}} моделей и их зависимостей.", + "starterBundleHelpText": "Легко установите все модели, необходимые для начала работы с базовой моделью, включая основную модель, ControlNet, IP-адаптеры и другие. При выборе набора уже установленные модели будут пропущены." }, "parameters": { "images": "Изображения", @@ -632,8 +641,8 @@ "canvasIsFiltering": "Холст фильтруется", "canvasIsTransforming": "Холст трансформируется", "noCLIPEmbedModelSelected": "Для генерации FLUX не выбрана модель CLIP Embed", - "canvasIsRasterizing": "Холст растрируется", - "canvasIsCompositing": "Холст составляется" + "canvasIsRasterizing": "Холст занят (идёт растеризация)", + "canvasIsCompositing": "Холст занят (идёт компоновка)" }, "cfgRescaleMultiplier": "Множитель масштабирования CFG", "patchmatchDownScaleSize": "уменьшить", @@ -660,7 +669,10 @@ "optimizedImageToImage": "Оптимизированное img2img", "sendToCanvas": "Отправить на холст", "guidance": "Точность", - "boxBlur": "Box Blur" + "boxBlur": "Box Blur", + "images_withCount_one": "Изображение", + "images_withCount_few": "Изображения", + "images_withCount_many": "Изображений" }, "settings": { "models": "Модели", @@ -690,7 +702,7 @@ "intermediatesCleared_one": "Очищено {{count}} промежуточное", "intermediatesCleared_few": "Очищено {{count}} промежуточных", "intermediatesCleared_many": "Очищено {{count}} промежуточных", - "clearIntermediatesDesc1": "Очистка промежуточных элементов приведет к сбросу состояния Canvas и ControlNet.", + "clearIntermediatesDesc1": "Очистка промежуточных данных приведёт к сбросу состояния холста и ControlNet.", "intermediatesClearedFailed": "Проблема очистки промежуточных", "reloadingIn": "Перезагрузка через", "informationalPopoversDisabled": "Информационные всплывающие окна отключены", @@ -704,7 +716,7 @@ "serverError": "Ошибка сервера", "connected": "Подключено к серверу", "canceled": "Обработка отменена", - "uploadFailedInvalidUploadDesc": "Это должны быть изображения PNG или JPEG.", + "uploadFailedInvalidUploadDesc": "Допускаются только изображения в формате PNG, JPEG или WEBP.", "parameterNotSet": "Параметр не задан", "parameterSet": "Параметр задан", "problemCopyingImage": "Не удается скопировать изображение", @@ -747,7 +759,12 @@ "sentToUpscale": "Отправить на увеличение", "linkCopied": "Ссылка скопирована", "addedToUncategorized": "Добавлено в активы доски $t(boards.uncategorized)", - "imagesWillBeAddedTo": "Загруженные изображения будут добавлены в активы доски {{boardName}}." + "imagesWillBeAddedTo": "Загруженные изображения будут добавлены в активы доски {{boardName}}.", + "schedulerResetZImageBase": "Планировщик LCM несовместим с моделями Z-Image Base. Переключено на Euler.", + "schedulerReset": "Планировщик сброшен", + "uploadFailedInvalidUploadDesc_withCount_one": "Допускается не более 1 изображения в формате PNG, JPEG или WEBP.", + "uploadFailedInvalidUploadDesc_withCount_few": "Допускается не более {{count}} изображения в формате PNG, JPEG или WEBP.", + "uploadFailedInvalidUploadDesc_withCount_many": "Допускается не более {{count}} изображений в формате PNG, JPEG или WEBP." }, "accessibility": { "uploadImage": "Загрузить изображение", @@ -892,7 +909,13 @@ "saveToGallery": "Сохранить в галерею", "noWorkflows": "Нет рабочих процессов", "noMatchingWorkflows": "Нет совпадающих рабочих процессов", - "workflowHelpText": "Нужна помощь? Ознакомьтесь с нашим руководством Getting Started with Workflows." + "workflowHelpText": "Нужна помощь? Ознакомьтесь с нашим руководством Getting Started with Workflows.", + "generatorImages_one": "{{count}} изображение", + "generatorImages_few": "{{count}} изображения", + "generatorImages_many": "{{count}} изображений", + "generatorNRandomValues_one": "{{count}} случайное значение", + "generatorNRandomValues_few": "{{count}} случайных значения", + "generatorNRandomValues_many": "{{count}} случайных значений" }, "boards": { "autoAddBoard": "Коллекция для автодобавления", @@ -935,7 +958,19 @@ "shared": "Общие коллекции", "noBoards": "Нет коллекций {{boardType}}", "deletedPrivateBoardsCannotbeRestored": "Удалённые коллекции и изображения нельзя восстановить. При выборе «Удалить только коллекцию» изображения будут перемещены в личный раздел «Без категории» автора изображения.", - "updateBoardError": "Ошибка обновления коллекции" + "updateBoardError": "Ошибка обновления коллекции", + "pause": "Пауза", + "resume": "Возобновить", + "restartFailed": "Ошибка перезапуска", + "restartFile": "Перезапустить загрузку", + "restartRequired": "Требуется перезапуск", + "resumeRefused": "Сервер отклонил попытку возобновления. Требуется перезапуск.", + "uncategorizedImages": "Без категории", + "deleteAllUncategorizedImages": "Удалить все изображения без категории", + "deletedImagesCannotBeRestored": "Удалённые изображения нельзя восстановить.", + "hideBoards": "Скрыть коллекции", + "locateInGalery": "Показать в галерее", + "viewBoards": "Просмотреть коллекции" }, "dynamicPrompts": { "seedBehaviour": { @@ -1031,19 +1066,19 @@ "controlNetResizeMode": { "heading": "Режим изменения размера", "paragraphs": [ - "Метод подгонки размера входного изображения Control Adaptor к размеру выходного изображения." + "Метод подгонки размера входного изображения Control Adapter под размер выходного изображения." ] }, "controlNetBeginEnd": { "paragraphs": [ - "Часть процесса шумоподавления, к которой будет применен адаптер контроля.", - "ControlNet, применяемые в начале процесса, направляют композицию, а ControlNet, применяемые в конце, направляют детали." + "Эта настройка определяет, на каком этапе денойзинга (генерации) используется влияние данного слоя.", + "• Начальный шаг (%): Определяет, с какого момента в процессе генерации начинает учитываться влияние данного слоя." ], "heading": "Процент начала/конца шага" }, "dynamicPromptsSeedBehaviour": { "paragraphs": [ - "Управляет использованием сида при создании запросов.", + "Определяет, как используется сид при генерации промптов.", "Для каждой итерации будет использоваться уникальный сид. Используйте это, чтобы изучить варианты запросов для одного сида.", "Например, если у вас 5 запросов, каждое изображение будет использовать один и то же сид.", "для каждого изображения будет использоваться уникальный сид. Это обеспечивает большую вариативность." @@ -1071,8 +1106,8 @@ }, "paramDenoisingStrength": { "paragraphs": [ - "Количество шума, добавляемого к входному изображению.", - "0 приведет к идентичному изображению, а 1 - к совершенно новому." + "Определяет, насколько сгенерированное изображение отличается от растрового слоя (слоёв).", + "Меньшее значение сохраняет больше сходства с объединёнными видимыми растровыми слоями. Большее значение усиливает влияние глобального промпта." ], "heading": "Шумоподавление" }, @@ -1111,7 +1146,7 @@ "controlNetWeight": { "heading": "Вес", "paragraphs": [ - "Вес адаптера управления. Более высокий вес приведет к большему воздействию на окончательное изображение." + "Определяет, насколько сильно слой влияет на процесс генерации." ] }, "controlNet": { @@ -1123,13 +1158,13 @@ "paramCFGScale": { "heading": "Шкала точности (CFG)", "paragraphs": [ - "Контролирует, насколько запрос влияет на процесс генерации.", + "Определяет, насколько сильно промпт влияет на процесс генерации.", "Высокие значения шкалы CFG могут привести к перенасыщению и искажению результатов генерации. " ] }, "controlNetControlMode": { "paragraphs": [ - "Придает больший вес либо запросу, либо ControlNet." + "Смещает приоритет в сторону промпта или ControlNet." ], "heading": "Режим управления" }, @@ -1181,7 +1216,7 @@ "refinerCfgScale": { "heading": "Шкала CFG", "paragraphs": [ - "Контролирует, насколько сильно запрос влияет на процесс генерации.", + "Определяет, насколько сильно промпт влияет на процесс генерации.", "Аналогично CFG шкале генерации." ] }, @@ -1290,24 +1325,24 @@ "ipAdapterMethod": { "heading": "Метод", "paragraphs": [ - "Метод, с помощью которого применяется текущий IP-адаптер." + "Метод определяет, как референсное изображение будет влиять на процесс генерации." ] }, "structure": { "paragraphs": [ - "Структура контролирует, насколько точно выходное изображение будет соответствовать макету оригинала. Низкая структура допускает значительные изменения, в то время как высокая структура строго сохраняет исходную композицию и макет." + "Структура определяет, насколько точно выходное изображение сохраняет компоновку исходного. Низкое значение допускает значительные изменения, а высокое строго сохраняет исходную композицию и расположение элементов." ], "heading": "Структура" }, "scale": { "paragraphs": [ - "Масштаб управляет размером выходного изображения и основывается на кратном разрешении входного изображения. Например, при увеличении в 2 раза изображения 1024x1024 на выходе получится 2048 x 2048." + "Масштаб определяет размер выходного изображения и рассчитывается как кратное разрешению исходного изображения. Например, увеличение в 2 раза для изображения 1024×1024 даст результат 2048×2048." ], "heading": "Масштаб" }, "creativity": { "paragraphs": [ - "Креативность контролирует степень свободы, предоставляемой модели при добавлении деталей. При низкой креативности модель остается близкой к оригинальному изображению, в то время как высокая креативность позволяет вносить больше изменений. При использовании подсказки высокая креативность увеличивает влияние подсказки." + "Креативность определяет степень свободы модели при добавлении деталей. Низкое значение сохраняет больше сходства с исходным изображением, а высокое допускает более значительные изменения. При использовании промпта высокое значение усиливает его влияние." ], "heading": "Креативность" }, @@ -1320,18 +1355,18 @@ "fluxDevLicense": { "heading": "Некоммерческая лицензия", "paragraphs": [ - "Модели FLUX.1 [dev] лицензируются по некоммерческой лицензии FLUX [dev]. Чтобы использовать этот тип модели в коммерческих целях в Invoke, посетите наш веб-сайт, чтобы узнать больше." + "Модели FLUX.1 [dev] распространяются по некоммерческой лицензии FLUX [dev]. Для их коммерческого использования требуется отдельная лицензия." ] }, "optimizedDenoising": { "heading": "Оптимизированный img2img", "paragraphs": [ - "Включите опцию «Оптимизированный img2img», чтобы получить более плавную шкалу Denoise Strength для img2img и перерисовки с моделями Flux. Эта настройка улучшает возможность контролировать степень изменения изображения, но может быть отключена, если вы предпочитаете использовать стандартную шкалу Denoise Strength. Эта настройка все еще находится в стадии настройки и в настоящее время имеет статус бета-версии." + "Включите «Optimized Image-to-Image», чтобы использовать более плавную шкалу Denoise Strength для преобразований image-to-image и инпейнтинга с моделями Flux. Эта настройка улучшает контроль над степенью изменений изображения, однако её можно отключить, если вы предпочитаете стандартную шкалу Denoise Strength. Функция находится в стадии настройки и имеет статус бета-версии." ] }, "paramGuidance": { "paragraphs": [ - "Контролирует, насколько сильно запрос влияет на процесс генерации.", + "Определяет, насколько сильно промпт влияет на процесс генерации.", "Высокие значения точности могут привести к перенасыщению, а высокие или низкие значения точности могут привести к искажению результатов генерации. Точность применима только к моделям FLUX DEV." ], "heading": "Точность" @@ -1363,7 +1398,7 @@ "parameterSet": "Параметр {{parameter}} установлен", "allPrompts": "Все запросы", "imageDimensions": "Размеры изображения", - "canvasV2Metadata": "Холст", + "canvasV2Metadata": "Слои холста", "guidance": "Точность" }, "queue": { @@ -1393,7 +1428,7 @@ "graphQueued": "График поставлен в очередь", "queue": "Очередь", "batch": "Пакет", - "clearQueueAlertDialog": "Очистка очереди немедленно отменяет все элементы обработки и полностью очищает очередь. Ожидающие фильтры будут отменены.", + "clearQueueAlertDialog": "Очистка очереди немедленно отменит все текущие задачи и очистит очередь. Ожидающие фильтры будут отменены, а область предпросмотра на холсте сброшена.", "pending": "В ожидании", "completedIn": "Завершено за", "resumeFailed": "Проблема с возобновлением рендеринга", @@ -1477,7 +1512,7 @@ "workflowEditorMenu": "Меню редактора рабочего процесса", "workflowName": "Имя рабочего процесса", "saveWorkflow": "Сохранить рабочий процесс", - "workflowLibrary": "Библиотека", + "workflowLibrary": "Библиотека схем генерации", "downloadWorkflow": "Сохранить в файл", "workflowSaved": "Рабочий процесс сохранен", "unnamedWorkflow": "Безымянный рабочий процесс", @@ -1560,7 +1595,7 @@ "autoNegative": "Авто негатив", "rectangle": "Прямоугольник", "addNegativePrompt": "Добавить $t(controlLayers.negativePrompt)", - "regionalGuidance": "Региональная точность", + "regionalGuidance": "Региональное влияние", "opacity": "Непрозрачность", "addLayer": "Добавить слой", "moveToFront": "На передний план", @@ -1568,33 +1603,33 @@ "regional": "Региональный", "bookmark": "Закладка для быстрого переключения", "fitBboxToLayers": "Подогнать рамку к слоям", - "mergeVisibleOk": "Объединенные видимые слои", - "mergeVisibleError": "Ошибка объединения видимых слоев", + "mergeVisibleOk": "Объединенные слои", + "mergeVisibleError": "Ошибка объединения слоев", "clearHistory": "Очистить историю", "mergeVisible": "Объединить видимые", "removeBookmark": "Удалить закладку", - "saveLayerToAssets": "Сохранить слой в активы", + "saveLayerToAssets": "Сохранить слой в ресурсы", "clearCaches": "Очистить кэши", "recalculateRects": "Пересчитать прямоугольники", - "saveBboxToGallery": "Сохранить рамку в галерею", + "saveBboxToGallery": "Сохранить область в галерею", "canvas": "Холст", "global": "Глобальный", - "newGlobalReferenceImageError": "Проблема с созданием глобального эталонного изображения", - "newRegionalReferenceImageOk": "Создано региональное эталонное изображение", - "newRegionalReferenceImageError": "Проблема создания регионального эталонного изображения", + "newGlobalReferenceImageError": "Проблема с созданием глобального референсного изображения", + "newRegionalReferenceImageOk": "Создано региональное референсное изображение", + "newRegionalReferenceImageError": "Проблема создания регионального референсного изображения", "newControlLayerOk": "Создан слой управления", "newControlLayerError": "Ошибка создания слоя управления", "newRasterLayerOk": "Создан растровый слой", "newRasterLayerError": "Ошибка создания растрового слоя", - "newGlobalReferenceImageOk": "Создано глобальное эталонное изображение", - "bboxOverlay": "Показать наложение ограничительной рамки", + "newGlobalReferenceImageOk": "Создано глобальное референсное изображение", + "bboxOverlay": "Показать наложение рамки", "saveCanvasToGallery": "Сохранить холст в галерею", - "pullBboxIntoReferenceImageOk": "рамка перенесена в эталонное изображение", - "pullBboxIntoReferenceImageError": "Ошибка переноса рамки в эталонное изображение", + "pullBboxIntoReferenceImageOk": "Рамка перенесена в референсное изображение", + "pullBboxIntoReferenceImageError": "Ошибка переноса рамки в референсное изображение", "regionIsEmpty": "Выбранный регион пуст", "savedToGalleryOk": "Сохранено в галерею", "savedToGalleryError": "Ошибка сохранения в галерею", - "pullBboxIntoLayerOk": "Рамка перенесена в слой", + "pullBboxIntoLayerOk": "Содержимое рамки перенесено в слой", "pullBboxIntoLayerError": "Проблема с переносом рамки в слой", "newLayerFromImage": "Новый слой из изображения", "filter": { @@ -1693,11 +1728,12 @@ "isTransforming": "{{title}} трансформируется" }, "scaledBbox": "Масштабированная рамка", - "bbox": "Ограничительная рамка" + "bbox": "Ограничительная рамка", + "textSessionActive": "Активен режим ввода" }, "canvasContextMenu": { "saveBboxToGallery": "Сохранить рамку в галерею", - "newGlobalReferenceImage": "Новое глобальное эталонное изображение", + "newGlobalReferenceImage": "Новое глобальное референсное изображение", "bboxGroup": "Сохдать из рамки", "canvasGroup": "Холст", "newControlLayer": "Новый контрольный слой", @@ -1709,8 +1745,8 @@ }, "fill": { "solid": "Сплошной", - "fillStyle": "Стиль заполнения", - "fillColor": "Цвет заполнения", + "fillStyle": "Стиль заливки", + "fillColor": "Цвет заливкии", "grid": "Сетка", "horizontal": "Горизонтальная", "diagonal": "Диагональная", @@ -1729,8 +1765,8 @@ "inpaintMask": "Маска перерисовки", "sendToCanvas": "Отправить на холст", "regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)", - "regionalGuidance_withCount_few": "Региональных точности", - "regionalGuidance_withCount_many": "Региональных точностей", + "regionalGuidance_withCount_few": "Региональных влияния", + "regionalGuidance_withCount_many": "Региональных влияний", "controlLayer_withCount_one": "$t(controlLayers.controlLayer)", "controlLayer_withCount_few": "Контрольных слоя", "controlLayer_withCount_many": "Контрольных слоев", @@ -1739,9 +1775,9 @@ "inpaintMask_withCount_few": "Маски перерисовки", "inpaintMask_withCount_many": "Масок перерисовки", "controlMode": { - "prompt": "Запрос", + "prompt": "Промпт", "controlMode": "Режим контроля", - "megaControl": "Мега контроль", + "megaControl": "Максимальный контроль", "balanced": "Сбалансированный", "control": "Контроль" }, @@ -1770,24 +1806,25 @@ "showResultsOn": "Показать результаты", "showResultsOff": "Скрыть результаты" }, - "pullBboxIntoReferenceImage": "Поместить рамку в эталонное изображение", + "pullBboxIntoReferenceImage": "Преобразовать рамку в референсное изображение", "enableAutoNegative": "Включить авто негатив", - "maskFill": "Заполнение маски", + "maskFill": "Заливка маски", "tool": { - "move": "Двигать", + "move": "Перемещение", "bbox": "Ограничительная рамка", - "view": "Смотреть", + "view": "Перемещение холста", "brush": "Кисть", "eraser": "Ластик", "rectangle": "Прямоугольник", - "colorPicker": "Подборщик цветов" + "colorPicker": "Пипетка", + "text": "Текст" }, "rasterLayer": "Растровый слой", "enableTransparencyEffect": "Включить эффект прозрачности", "hidingType": "Скрыть {{type}}", "addRegionalGuidance": "Добавить $t(controlLayers.regionalGuidance)", "deleteSelected": "Удалить выбранное", - "pullBboxIntoLayer": "Поместить рамку в слой", + "pullBboxIntoLayer": "Преобразовать рамку в слой", "locked": "Заблокировано", "replaceLayer": "Заменить слой", "width": "Ширина", @@ -1795,15 +1832,15 @@ "addRasterLayer": "Добавить $t(controlLayers.rasterLayer)", "addControlLayer": "Добавить $t(controlLayers.controlLayer)", "addInpaintMask": "Добавить $t(controlLayers.inpaintMask)", - "cropLayerToBbox": "Обрезать слой по ограничительной рамке", - "clipToBbox": "Обрезка штрихов в рамке", - "outputOnlyMaskedRegions": "Вывод только маскированных областей", + "cropLayerToBbox": "Обрезать слой по рамке", + "clipToBbox": "Ограничить мазки рамкой", + "outputOnlyMaskedRegions": "Выводить только сгенерированные области", "duplicate": "Дублировать", "layer_one": "Слой", "layer_few": "Слоя", "layer_many": "Слоев", - "prompt": "Запрос", - "negativePrompt": "Исключающий запрос", + "prompt": "Промпт", + "negativePrompt": "Негативный промпт", "beginEndStepPercentShort": "Начало/конец %", "transform": { "transform": "Трансформировать", @@ -1816,7 +1853,7 @@ "fitModeFill": "Заполнить" }, "disableAutoNegative": "Отключить авто негатив", - "deleteReferenceImage": "Удалить эталонное изображение", + "deleteReferenceImage": "Удалить референсное изображение", "rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)", "rasterLayer_withCount_few": "Растровых слоя", "rasterLayer_withCount_many": "Растровых слоев", @@ -1828,9 +1865,42 @@ "logDebugInfo": "Писать отладочную информацию", "unlocked": "Разблокировано", "showProgressOnCanvas": "Показать прогресс на холсте", - "regionalReferenceImage": "Региональное эталонное изображение", - "globalReferenceImage": "Глобальное эталонное изображение", - "referenceImage": "Эталонное изображение" + "regionalReferenceImage": "Региональное референсное изображение", + "globalReferenceImage": "Глобальное референсное изображение", + "referenceImage": "Референсное изображение", + "text": { + "px": "px", + "alignRight": "По правому краю", + "alignCenter": "По центру", + "alignLeft": "По левому краю", + "strikethrough": "Зачёркнутый", + "italic": "Курсив", + "bold": "Полужирный", + "size": "Размер", + "font": "Шрифт" + }, + "newImg2ImgCanvasFromImage": "Новое изображение из Img2Img", + "sendToCanvasDesc": "При нажатии Invoke результат появляется на холсте в режиме предпросмотра.", + "compositeOperation": { + "blendModes": { + "darken": "Затемнение", + "multiply": "Умножение", + "color-dodge": "Осветление основы", + "color-burn": "Затемнение основы", + "screen": "Экран", + "hard-light": "Жёсткий свет", + "soft-light": "Мягкий свет", + "overlay": "Перекрытие", + "hue": "Тон", + "color": "Цвет", + "source-over": "Обычный" + } + }, + "globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)", + "globalReferenceImage_withCount_few": "Глобальных референсных изображения", + "globalReferenceImage_withCount_many": "Глобальных референсных изображений", + "regionalGuidance_withCount_hidden": "Региональное влияние (скрыто: {{count}})", + "controlLayers_withCount_hidden": "Слои управления (скрыто: {{count}})" }, "ui": { "tabs": { From ec46b5cb9eb00f9e34f0196c6dd6f6cdc087b1ad Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 28 Feb 2026 16:59:45 +0100 Subject: [PATCH 05/24] Fix: Replace deprecated huggingface_hub.get_token_permission() with whoami() (#8913) `get_token_permission` is deprecated and will be removed in huggingface_hub 1.0. Use `whoami()` to validate the token instead, as recommended by the deprecation warning. --- invokeai/app/api/routers/model_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index a1f6b3a744..234c6c9662 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -1141,11 +1141,11 @@ class HFTokenHelper: @classmethod def get_status(cls) -> HFTokenStatus: try: - if huggingface_hub.get_token_permission(huggingface_hub.get_token()): - # Valid token! - return HFTokenStatus.VALID - # No token set - return HFTokenStatus.INVALID + token = huggingface_hub.get_token() + if not token: + return HFTokenStatus.INVALID + huggingface_hub.whoami(token=token) + return HFTokenStatus.VALID except Exception: return HFTokenStatus.UNKNOWN From 54c1609687de6bc5b8c7b120be880ceb63b0441c Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 28 Feb 2026 17:22:29 +0100 Subject: [PATCH 06/24] Filter non-transformer keys from Z-Image checkpoint state dicts (#8918) Merged Z-Image checkpoints (e.g. models with LoRAs baked in) may bundle text encoder weights (text_encoders.*) or other non-transformer keys alongside the transformer weights. These cause load_state_dict() to fail with strict=True. Instead of disabling strict mode, explicitly whitelist valid ZImageTransformer2DModel key prefixes and discard everything else. Also moves RAM allocation after filtering so it doesn't over-allocate for discarded keys. Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com> --- .../load/model_loaders/z_image.py | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/invokeai/backend/model_manager/load/model_loaders/z_image.py b/invokeai/backend/model_manager/load/model_loaders/z_image.py index aadced8f56..c381e02718 100644 --- a/invokeai/backend/model_manager/load/model_loaders/z_image.py +++ b/invokeai/backend/model_manager/load/model_loaders/z_image.py @@ -253,16 +253,35 @@ class ZImageCheckpointModel(ModelLoader): target_device = TorchDevice.choose_torch_device() model_dtype = TorchDevice.choose_bfloat16_safe_dtype(target_device) + # Filter out keys that don't belong to the ZImageTransformer2DModel. + # Merged checkpoints (e.g. LoRA-baked models) may bundle text encoder weights + # (text_encoders.*) or other non-transformer keys alongside the transformer weights. + # Also filter FP8 quantization metadata (scale_weight, scaled_fp8). + valid_prefixes = ( + "all_x_embedder.", + "all_final_layer.", + "layers.", + "noise_refiner.", + "context_refiner.", + "t_embedder.", + "cap_embedder.", + "rope_embedder.", + ) + valid_exact = {"x_pad_token", "cap_pad_token"} + keys_to_remove = [ + k + for k in sd.keys() + if not (k.startswith(valid_prefixes) or k in valid_exact) + or k.endswith(".scale_weight") + or k == "scaled_fp8" + ] + for k in keys_to_remove: + del sd[k] + # Handle memory management and dtype conversion new_sd_size = sum([ten.nelement() * model_dtype.itemsize for ten in sd.values()]) self._ram_cache.make_room(new_sd_size) - # Filter out FP8 scale_weight and scaled_fp8 metadata keys - # These are quantization metadata that shouldn't be loaded into the model - keys_to_remove = [k for k in sd.keys() if k.endswith(".scale_weight") or k == "scaled_fp8"] - for k in keys_to_remove: - del sd[k] - # Convert to target dtype for k in sd.keys(): sd[k] = sd[k].to(model_dtype) From 445c6a3c369edf2dcc191085f24d4d095c96899f Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:31:53 +0200 Subject: [PATCH 07/24] Fix(MM): Fixed incorrect advertised model size for Z-Image Turbo (#8934) --- invokeai/backend/model_manager/starter_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py index ef7cd80cd2..9f86f83dc5 100644 --- a/invokeai/backend/model_manager/starter_models.py +++ b/invokeai/backend/model_manager/starter_models.py @@ -821,7 +821,7 @@ z_image_turbo = StarterModel( name="Z-Image Turbo", base=BaseModelType.ZImage, source="Tongyi-MAI/Z-Image-Turbo", - description="Z-Image Turbo - fast 6B parameter text-to-image model with 8 inference steps. Supports bilingual prompts (English & Chinese). ~13GB", + description="Z-Image Turbo - fast 6B parameter text-to-image model with 8 inference steps. Supports bilingual prompts (English & Chinese). ~30.6GB", type=ModelType.Main, ) From 6fe7910a906af88fc410c6dcb48af311af7b8bca Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Mon, 2 Mar 2026 04:44:21 +0200 Subject: [PATCH 08/24] fix(model-install): persist remote access_token for resume after restart (#8932) Co-authored-by: Lincoln Stein --- .../model_install/model_install_default.py | 12 +++- .../model_install/test_model_install.py | 63 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index c47267eab5..714ae9329a 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -132,6 +132,9 @@ class ModelInstallService(ModelInstallServiceBase): marker = { "version": INSTALL_MARKER_VERSION, "source": str(job.source), + "access_token": ( + job.source.access_token if isinstance(job.source, (HFModelSource, URLModelSource)) else None + ), "config_in": job.config_in.model_dump(), "status": (status or job.status).value, "updated_at": get_iso_timestamp(), @@ -200,7 +203,13 @@ class ModelInstallService(ModelInstallServiceBase): continue try: - source_str = marker["source"] + source_str = marker.get("source") + if not isinstance(source_str, str): + raise ValueError("Missing source in install marker") + source = self._guess_source(source_str) + access_token = marker.get("access_token") + if isinstance(source, (HFModelSource, URLModelSource)) and isinstance(access_token, str): + source.access_token = access_token if source_str in active_sources: # This tmpdir belongs to an install already in progress; leave it alone. self._logger.debug(f"Skipping restore for {source_str} - already being tracked") @@ -210,7 +219,6 @@ class ModelInstallService(ModelInstallServiceBase): self._safe_rmtree(tmpdir, self._logger) continue seen_sources.add(source_str) - source = self._guess_source(source_str) except Exception as e: self._logger.warning(f"Skipping install marker in {tmpdir}: {e}") continue diff --git a/tests/app/services/model_install/test_model_install.py b/tests/app/services/model_install/test_model_install.py index d19eb95a8c..9a7cc6aaaa 100644 --- a/tests/app/services/model_install/test_model_install.py +++ b/tests/app/services/model_install/test_model_install.py @@ -4,6 +4,7 @@ Test the model installer import gc import platform +import shutil import uuid from pathlib import Path from typing import Any, Dict @@ -23,6 +24,7 @@ from invokeai.app.services.events.events_common import ( ) from invokeai.app.services.model_install import ( HFModelSource, + ModelInstallService, ModelInstallServiceBase, ) from invokeai.app.services.model_install.model_install_common import ( @@ -343,6 +345,67 @@ def test_huggingface_repo_id(mm2_installer: ModelInstallServiceBase, mm2_app_con assert job.total_bytes == sum(x["total_bytes"] for x in downloading_events[-1].parts) +def test_restore_paused_hf_install_preserves_access_token( + mm2_installer: ModelInstallServiceBase, + mm2_app_config: InvokeAIAppConfig, + mm2_download_queue, + mm2_session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + assert isinstance(mm2_installer, ModelInstallService) + + access_token = "hf_test_access_token" + tmpdir = mm2_app_config.models_path / f"tmpinstall_resume_token_{uuid.uuid4().hex}" + tmpdir.mkdir(parents=True, exist_ok=True) + + try: + paused_job = ModelInstallJob( + id=99999, + source=HFModelSource( + repo_id="stabilityai/sdxl-turbo", + variant=ModelRepoVariant.Default, + access_token=access_token, + ), + config_in=ModelRecordChanges(), + local_path=tmpdir, + ) + paused_job._install_tmpdir = tmpdir + paused_job.status = InstallStatus.PAUSED + + mm2_installer._write_install_marker(paused_job, status=InstallStatus.PAUSED) + + marker = mm2_installer._read_install_marker(tmpdir) + assert marker is not None + assert marker["access_token"] == access_token + + restored_installer = ModelInstallService( + app_config=mm2_app_config, + record_store=mm2_installer.record_store, + download_queue=mm2_download_queue, + session=mm2_session, + ) + restored_installer._restore_incomplete_installs() + restored_jobs = restored_installer.list_jobs() + assert len(restored_jobs) == 1 + + restored_job = restored_jobs[0] + assert restored_job.paused + assert isinstance(restored_job.source, HFModelSource) + assert restored_job.source.access_token == access_token + + captured: dict[str, str | None] = {} + + def _capture_resume(job: ModelInstallJob) -> None: + assert isinstance(job.source, HFModelSource) + captured["access_token"] = job.source.access_token + + monkeypatch.setattr(restored_installer, "_resume_remote_download", _capture_resume) + restored_installer.resume_job(restored_job) + assert captured["access_token"] == access_token + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + def test_404_download(mm2_installer: ModelInstallServiceBase, mm2_app_config: InvokeAIAppConfig) -> None: source = URLModelSource(url=Url("https://test.com/missing_model.safetensors")) job = mm2_installer.import_model(source) From 6b57b004a4bb0c6f51d2304895a15860071766f2 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Mon, 2 Mar 2026 04:03:10 +0100 Subject: [PATCH 09/24] feat(MM):model settings export import (#8872) * feat(model_manager): add export/import for model settings Add the ability to export model settings (default_settings, trigger_phrases, cpu_only) as JSON and import them back. The model name is used as the filename for exports. https://claude.ai/code/session_01LXKjbRjfzcG3d3vzk3xRCh * fix(ui): reset settings forms after import so updated values display immediately The useForm defaultValues only apply on mount, so importing model settings updated the backend but the forms kept showing stale values. Added useEffect to reset forms when the underlying model config changes. Also fixed lint errors (strict equality, missing React import). * fix(ui): harden model settings export/import Prevent cross-model-type import errors by filtering imported fields against the target model's supported fields, showing clear warnings for incompatible or partially compatible settings instead of raw pydantic validation errors. Also fix falsy checks for empty arrays and objects in export, disable export button when nothing to export, add client-side validation and FileReader error handling on import. * Chore pnpm fix --------- Co-authored-by: Claude Co-authored-by: Lincoln Stein --- invokeai/frontend/web/public/locales/en.json | 10 +- .../ControlAdapterModelDefaultSettings.tsx | 6 +- .../EncoderModelSettings.tsx | 6 +- .../LoRAModelDefaultSettings.tsx | 6 +- .../MainModelDefaultSettings.tsx | 6 +- .../ModelPanel/ModelSettingsExportButton.tsx | 81 +++++++++ .../ModelPanel/ModelSettingsImportButton.tsx | 172 ++++++++++++++++++ .../subpanels/ModelPanel/ModelView.tsx | 4 + 8 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c28df6ee38..2db971d06a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1164,7 +1164,15 @@ "installingXModels_other": "Installing {{count}} models", "skippingXDuplicates_one": ", skipping {{count}} duplicate", "skippingXDuplicates_other": ", skipping {{count}} duplicates", - "manageModels": "Manage Models" + "manageModels": "Manage Models", + "exportSettings": "Export Settings", + "importSettings": "Import Settings", + "settingsExported": "Model settings exported", + "settingsImported": "Model settings imported", + "settingsImportedPartial": "Model settings partially imported. Incompatible settings were skipped: {{fields}}", + "settingsImportFailed": "Failed to import model settings", + "settingsImportIncompatible": "The settings file contains no compatible settings for this model type", + "settingsImportInvalidFile": "Invalid settings file" }, "models": { "addLora": "Add LoRA", diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx index 92d509011c..a5e8f10a4b 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/ControlAdapterModelDefaultSettings.tsx @@ -4,7 +4,7 @@ import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsMod import { DefaultPreprocessor } from 'features/modelManagerV2/subpanels/ModelPanel/ControlAdapterModelDefaultSettings/DefaultPreprocessor'; import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; import { toast } from 'features/toast/toast'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -32,6 +32,10 @@ export const ControlAdapterModelDefaultSettings = memo(({ modelConfig }: Props) defaultValues: defaultSettingsDefaults, }); + useEffect(() => { + reset(defaultSettingsDefaults); + }, [defaultSettingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { const body = { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx index bf1690359e..e10766214f 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/EncoderModelSettings/EncoderModelSettings.tsx @@ -6,7 +6,7 @@ import { selectSelectedModelKey } from 'features/modelManagerV2/store/modelManag import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; import { toast } from 'features/toast/toast'; import type { ChangeEvent } from 'react'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo } from 'react'; import type { Control, SubmitHandler } from 'react-hook-form'; import { useController, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -80,6 +80,10 @@ export const EncoderModelSettings = memo(({ modelConfig }: Props) => { defaultValues: settingsDefaults, }); + useEffect(() => { + reset(settingsDefaults); + }, [settingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { if (!selectedModelKey) { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx index d2f55540af..2f509caa72 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx @@ -4,7 +4,7 @@ import { useLoRAModelDefaultSettings } from 'features/modelManagerV2/hooks/useLo import { DefaultWeight } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight'; import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; import { toast } from 'features/toast/toast'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -32,6 +32,10 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { defaultValues: defaultSettingsDefaults, }); + useEffect(() => { + reset(defaultSettingsDefaults); + }, [defaultSettingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { const body = { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx index 8497eee02e..dd944897b2 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings.tsx @@ -8,7 +8,7 @@ import { DefaultWidth } from 'features/modelManagerV2/subpanels/ModelPanel/MainM import type { ParameterScheduler } from 'features/parameters/types/parameterSchemas'; import { getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { toast } from 'features/toast/toast'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo } from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -65,6 +65,10 @@ export const MainModelDefaultSettings = memo(({ modelConfig }: Props) => { defaultValues: defaultSettingsDefaults, }); + useEffect(() => { + reset(defaultSettingsDefaults); + }, [defaultSettingsDefaults, reset]); + const onSubmit = useCallback>( (data) => { if (!selectedModelKey) { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx new file mode 100644 index 0000000000..c5f2ae0a0b --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton.tsx @@ -0,0 +1,81 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDownloadSimpleBold } from 'react-icons/pi'; +import type { AnyModelConfig } from 'services/api/types'; + +type Props = { + modelConfig: AnyModelConfig; +}; + +const buildExportData = (modelConfig: AnyModelConfig): Record => { + const data: Record = {}; + + if ( + 'default_settings' in modelConfig && + modelConfig.default_settings !== undefined && + modelConfig.default_settings !== null + ) { + data.default_settings = modelConfig.default_settings; + } + + if ( + 'trigger_phrases' in modelConfig && + modelConfig.trigger_phrases !== undefined && + modelConfig.trigger_phrases !== null + ) { + data.trigger_phrases = modelConfig.trigger_phrases; + } + + if ('cpu_only' in modelConfig && modelConfig.cpu_only !== null) { + data.cpu_only = modelConfig.cpu_only; + } + + return data; +}; + +const sanitizeFilename = (name: string): string => { + return name.replace(/[<>:"/\\|?*]/g, '_'); +}; + +export const ModelSettingsExportButton = memo(({ modelConfig }: Props) => { + const { t } = useTranslation(); + + const hasExportableData = useMemo(() => Object.keys(buildExportData(modelConfig)).length > 0, [modelConfig]); + + const handleExport = useCallback(() => { + const data = buildExportData(modelConfig); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const filename = `${sanitizeFilename(modelConfig.name)}.json`; + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ + id: 'SETTINGS_EXPORTED', + title: t('modelManager.settingsExported'), + status: 'success', + }); + }, [modelConfig, t]); + + return ( + } + aria-label={t('modelManager.exportSettings')} + tooltip={t('modelManager.exportSettings')} + onClick={handleExport} + isDisabled={!hasExportableData} + /> + ); +}); + +ModelSettingsExportButton.displayName = 'ModelSettingsExportButton'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx new file mode 100644 index 0000000000..474afcd74e --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton.tsx @@ -0,0 +1,172 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiUploadSimpleBold } from 'react-icons/pi'; +import { useUpdateModelMutation } from 'services/api/endpoints/models'; +import type { AnyModelConfig } from 'services/api/types'; + +const validateImportData = (data: unknown): data is Record => { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return false; + } + + const obj = data as Record; + + if ('trigger_phrases' in obj && obj.trigger_phrases !== undefined) { + if (!Array.isArray(obj.trigger_phrases) || !obj.trigger_phrases.every((p) => typeof p === 'string')) { + return false; + } + } + + if ('default_settings' in obj && obj.default_settings !== undefined) { + if ( + typeof obj.default_settings !== 'object' || + obj.default_settings === null || + Array.isArray(obj.default_settings) + ) { + return false; + } + } + + if ('cpu_only' in obj && obj.cpu_only !== undefined) { + if (typeof obj.cpu_only !== 'boolean') { + return false; + } + } + + return true; +}; + +type Props = { + modelConfig: AnyModelConfig; +}; + +export const ModelSettingsImportButton = memo(({ modelConfig }: Props) => { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const [updateModel] = useUpdateModelMutation(); + + const applySettings = useCallback( + async (data: Record) => { + const body: Record = {}; + const skippedFields: string[] = []; + + const importableFields = ['default_settings', 'trigger_phrases', 'cpu_only'] as const; + + for (const field of importableFields) { + if (!(field in data) || data[field] === undefined || data[field] === null) { + continue; + } + if (field in modelConfig) { + body[field] = data[field]; + } else { + skippedFields.push(field); + } + } + + if (Object.keys(body).length === 0) { + if (skippedFields.length > 0) { + toast({ + id: 'SETTINGS_IMPORT_INCOMPATIBLE', + title: t('modelManager.settingsImportIncompatible'), + status: 'warning', + }); + } + return; + } + + await updateModel({ + key: modelConfig.key, + body, + }) + .unwrap() + .then(() => { + if (skippedFields.length > 0) { + toast({ + id: 'SETTINGS_IMPORTED', + title: t('modelManager.settingsImportedPartial', { fields: skippedFields.join(', ') }), + status: 'warning', + }); + } else { + toast({ + id: 'SETTINGS_IMPORTED', + title: t('modelManager.settingsImported'), + status: 'success', + }); + } + }) + .catch((_error) => { + toast({ + id: 'SETTINGS_IMPORT_FAILED', + title: t('modelManager.settingsImportFailed'), + status: 'error', + }); + }); + }, + [modelConfig, updateModel, t] + ); + + const handleFileChange = useCallback( + (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const json = JSON.parse(event.target?.result as string); + if (!validateImportData(json)) { + toast({ + id: 'SETTINGS_IMPORT_INVALID', + title: t('modelManager.settingsImportInvalidFile'), + status: 'error', + }); + return; + } + applySettings(json); + } catch { + toast({ + id: 'SETTINGS_IMPORT_INVALID', + title: t('modelManager.settingsImportInvalidFile'), + status: 'error', + }); + } + }; + reader.onerror = () => { + toast({ + id: 'SETTINGS_IMPORT_INVALID', + title: t('modelManager.settingsImportInvalidFile'), + status: 'error', + }); + }; + reader.readAsText(file); + + // Reset the input so the same file can be re-selected + e.target.value = ''; + }, + [applySettings, t] + ); + + const handleClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + return ( + <> + } + aria-label={t('modelManager.importSettings')} + tooltip={t('modelManager.importSettings')} + onClick={handleClick} + /> + + + ); +}); + +ModelSettingsImportButton.displayName = 'ModelSettingsImportButton'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx index 6e114bb252..f29846e6f8 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/ModelView.tsx @@ -6,6 +6,8 @@ import { LoRAModelDefaultSettings } from 'features/modelManagerV2/subpanels/Mode import { ModelConvertButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelConvertButton'; import { ModelEditButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelEditButton'; import { ModelHeader } from 'features/modelManagerV2/subpanels/ModelPanel/ModelHeader'; +import { ModelSettingsExportButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelSettingsExportButton'; +import { ModelSettingsImportButton } from 'features/modelManagerV2/subpanels/ModelPanel/ModelSettingsImportButton'; import { TriggerPhrases } from 'features/modelManagerV2/subpanels/ModelPanel/TriggerPhrases'; import { filesize } from 'filesize'; import { memo, useMemo } from 'react'; @@ -88,6 +90,8 @@ export const ModelView = memo(({ modelConfig }: Props) => { {canManageModels && modelConfig.format === 'checkpoint' && modelConfig.type === 'main' && ( )} + {withSettings && } + {withSettings && } {canManageModels && } {canManageModels && } From c7bdaf93b2a612591674f3267af626863b5ae869 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 5 Mar 2026 22:01:40 -0500 Subject: [PATCH 10/24] Fix: Shut down the server with one keyboard interrupt (#94) (#8936) * Fix: Kill the server with one keyboard interrupt (#94) * Initial plan * Handle KeyboardInterrupt in run_app to allow single Ctrl+C shutdown Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Force os._exit(0) on KeyboardInterrupt to avoid hanging on background threads Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix graceful shutdown to wait for download/install worker threads (#102) * Initial plan * Replace os._exit(0) with ApiDependencies.shutdown() on KeyboardInterrupt Instead of immediately force-exiting the process on CTRL+C, call ApiDependencies.shutdown() to gracefully stop the download and install manager services, allowing active work to complete or cancel cleanly before the process exits. Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Make stop() idempotent in download and model install services When CTRL+C is pressed, uvicorn's graceful shutdown triggers the FastAPI lifespan which calls ApiDependencies.shutdown(), then a KeyboardInterrupt propagates from run_until_complete() hitting the except block which tries to call ApiDependencies.shutdown() a second time. Change both stop() methods to return silently (instead of raising) when the service is not running. This handles: - Double-shutdown: lifespan already stopped the services - Early interrupt: services were never fully started Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix shutdown hang on session processor thread lock (#108) * Initial plan * Fix shutdown hang: wake session processor thread on stop() and mark daemon Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * Fix: shut down asyncio executor on KeyboardInterrupt to prevent post-generation hang (#112) Fix: cancel pending asyncio tasks before loop.close() to suppress destroyed-task warnings Fix: suppress stack trace when dispatching events after event loop is closed on shutdown Fix: cancel in-progress generation on stop() to prevent core dump during mid-flight Ctrl+C Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- invokeai/app/run_app.py | 44 +++++- .../app/services/download/download_default.py | 2 +- .../services/events/events_fastapievents.py | 4 + .../model_install/model_install_default.py | 2 +- .../session_processor_default.py | 9 ++ tests/test_asyncio_shutdown.py | 147 ++++++++++++++++++ 6 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 tests/test_asyncio_shutdown.py diff --git a/invokeai/app/run_app.py b/invokeai/app/run_app.py index 76c2ffdad5..febd4f4d4b 100644 --- a/invokeai/app/run_app.py +++ b/invokeai/app/run_app.py @@ -9,6 +9,11 @@ def get_app(): def run_app() -> None: """The main entrypoint for the app.""" + import asyncio + import sys + import threading + import traceback + from invokeai.frontend.cli.arg_parser import InvokeAIArgs # Parse the CLI arguments before doing anything else, which ensures CLI args correctly override settings from other @@ -100,4 +105,41 @@ def run_app() -> None: for hdlr in logger.handlers: uvicorn_logger.addHandler(hdlr) - loop.run_until_complete(server.serve()) + try: + loop.run_until_complete(server.serve()) + except KeyboardInterrupt: + logger.info("InvokeAI shutting down...") + # Gracefully shut down services (e.g. model download and install managers) so that any + # active work is completed or cleanly cancelled before the process exits. + from invokeai.app.api.dependencies import ApiDependencies + + ApiDependencies.shutdown() + + # Cancel any pending asyncio tasks (e.g. socket.io ping tasks) so that loop.close() does + # not emit "Task was destroyed but it is pending!" warnings for each one. + pending = [t for t in asyncio.all_tasks(loop) if not t.done()] + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + + # Shut down the asyncio default thread executor. asyncio.to_thread() (used e.g. in the + # session queue for SQLite operations during generation) creates non-daemon threads via the + # event loop's default ThreadPoolExecutor. Without this call those threads remain alive and + # cause threading._shutdown() to hang indefinitely after the process's main code finishes. + loop.run_until_complete(loop.shutdown_default_executor()) + loop.close() + + # After graceful shutdown, log any non-daemon threads that are still alive. These are the + # threads that will cause Python's threading._shutdown() to block, preventing the process + # from exiting cleanly. This helps identify threads that need to be fixed or joined. + frames = sys._current_frames() + for thread in threading.enumerate(): + if thread.daemon or thread is threading.main_thread(): + continue + frame = frames.get(thread.ident) + stack = "".join(traceback.format_stack(frame)) if frame else "(no frame available)" + logger.warning( + f"Non-daemon thread still alive after shutdown: {thread.name!r} " + f"(ident={thread.ident})\nStack trace:\n{stack}" + ) diff --git a/invokeai/app/services/download/download_default.py b/invokeai/app/services/download/download_default.py index 9b5fda5620..c21ffde5a1 100644 --- a/invokeai/app/services/download/download_default.py +++ b/invokeai/app/services/download/download_default.py @@ -88,7 +88,7 @@ class DownloadQueueService(DownloadQueueServiceBase): """Stop the download worker threads.""" with self._lock: if not self._worker_pool: - raise Exception("Attempt to stop the download service before it was started") + return self._accept_download_requests = False # reject attempts to add new jobs to queue queued_jobs = [x for x in self.list_jobs() if x.status == DownloadJobStatus.WAITING] active_jobs = [x for x in self.list_jobs() if x.status == DownloadJobStatus.RUNNING] diff --git a/invokeai/app/services/events/events_fastapievents.py b/invokeai/app/services/events/events_fastapievents.py index 3c46b37fd6..f44eecc555 100644 --- a/invokeai/app/services/events/events_fastapievents.py +++ b/invokeai/app/services/events/events_fastapievents.py @@ -28,6 +28,10 @@ class FastAPIEventService(EventServiceBase): self._loop.call_soon_threadsafe(self._queue.put_nowait, None) def dispatch(self, event: EventBase) -> None: + if self._loop.is_closed(): + # The event loop was closed during shutdown. Events can no longer be dispatched; + # silently drop this one so the generation thread can wind down cleanly. + return self._loop.call_soon_threadsafe(self._queue.put_nowait, event) async def _dispatch_from_queue(self, stop_event: threading.Event): diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index 714ae9329a..f20a1784be 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -330,7 +330,7 @@ class ModelInstallService(ModelInstallServiceBase): def stop(self, invoker: Optional[Invoker] = None) -> None: """Stop the installer thread; after this the object can be deleted and garbage collected.""" if not self._running: - raise Exception("Attempt to stop the install service before it was started") + return self._logger.debug("calling stop_event.set()") self._stop_event.set() self._clear_pending_jobs() diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py index 6c320eabda..bda6ac98e3 100644 --- a/invokeai/app/services/session_processor/session_processor_default.py +++ b/invokeai/app/services/session_processor/session_processor_default.py @@ -355,6 +355,7 @@ class DefaultSessionProcessor(SessionProcessorBase): self._thread = Thread( name="session_processor", target=self._process, + daemon=True, kwargs={ "stop_event": self._stop_event, "poll_now_event": self._poll_now_event, @@ -366,6 +367,14 @@ class DefaultSessionProcessor(SessionProcessorBase): def stop(self, *args, **kwargs) -> None: self._stop_event.set() + # Cancel any in-progress generation so that long-running nodes (e.g. denoising) stop at + # the next step boundary instead of running to completion. Without this, the generation + # thread may still be executing CUDA operations when Python teardown begins, which can + # cause a C++ std::terminate() crash ("terminate called without an active exception"). + self._cancel_event.set() + # Wake the thread if it is sleeping in poll_now_event.wait() or blocked in resume_event.wait() (paused). + self._poll_now_event.set() + self._resume_event.set() def _poll_now(self) -> None: self._poll_now_event.set() diff --git a/tests/test_asyncio_shutdown.py b/tests/test_asyncio_shutdown.py new file mode 100644 index 0000000000..066ff937c9 --- /dev/null +++ b/tests/test_asyncio_shutdown.py @@ -0,0 +1,147 @@ +""" +Tests that verify the fix for the two-Ctrl+C shutdown hang. + +Root cause: asyncio.to_thread() (used during generation for SQLite session queue operations) +creates non-daemon threads via the event loop's default ThreadPoolExecutor. When the event +loop is interrupted by KeyboardInterrupt without calling loop.shutdown_default_executor() and +loop.close(), those non-daemon threads remain alive and cause threading._shutdown() to block. + +The fix in run_app.py: +1. Cancels all pending asyncio tasks (e.g. socket.io ping tasks) to avoid "Task was destroyed + but it is pending!" warnings when loop.close() is called. +2. Calls loop.run_until_complete(loop.shutdown_default_executor()) followed by loop.close() + after ApiDependencies.shutdown(), so all executor threads are cleaned up before the process + begins its Python-level teardown. +""" + +from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess + + +def test_asyncio_to_thread_creates_nondaemon_thread(): + """Confirm that asyncio.to_thread() leaves a non-daemon thread alive after run_until_complete() + is interrupted - this is the raw symptom that caused the two-Ctrl+C hang.""" + + def test_func(): + import asyncio + import threading + + async def use_thread(): + await asyncio.to_thread(lambda: None) + + loop = asyncio.new_event_loop() + loop.run_until_complete(use_thread()) + # Deliberately do NOT call shutdown_default_executor() or loop.close() + non_daemon = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()] + # There should be at least one non-daemon executor thread still alive + if not non_daemon: + raise AssertionError("Expected a non-daemon thread but found none") + print("ok") + + stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func) + assert returncode == 0, _stderr + assert stdout.strip() == "ok" + + +def test_shutdown_default_executor_cleans_up_nondaemon_threads(): + """Verify that calling shutdown_default_executor() + loop.close() eliminates all non-daemon + threads created by asyncio.to_thread() - this is the fix applied in run_app.py.""" + + def test_func(): + import asyncio + import threading + + async def use_thread(): + await asyncio.to_thread(lambda: None) + + loop = asyncio.new_event_loop() + loop.run_until_complete(use_thread()) + + # Apply the fix + loop.run_until_complete(loop.shutdown_default_executor()) + loop.close() + + non_daemon = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()] + if non_daemon: + raise AssertionError(f"Expected no non-daemon threads but found: {[t.name for t in non_daemon]}") + print("ok") + + stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func) + assert returncode == 0, _stderr + assert stdout.strip() == "ok" + + +def test_shutdown_default_executor_works_after_simulated_keyboard_interrupt(): + """Verify that the fix works even when run_until_complete() was previously interrupted, + matching the exact flow in run_app.py's except KeyboardInterrupt block.""" + + def test_func(): + import asyncio + import threading + + async def use_thread_then_raise(): + await asyncio.to_thread(lambda: None) + raise KeyboardInterrupt + + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(use_thread_then_raise()) + except KeyboardInterrupt: + pass + + # At this point a non-daemon thread exists (the bug) + non_daemon_before = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()] + if not non_daemon_before: + raise AssertionError("Expected a non-daemon thread before fix") + + # Apply the fix (what run_app.py now does) + loop.run_until_complete(loop.shutdown_default_executor()) + loop.close() + + non_daemon_after = [t for t in threading.enumerate() if not t.daemon and t is not threading.main_thread()] + if non_daemon_after: + raise AssertionError(f"Non-daemon threads remain after fix: {[t.name for t in non_daemon_after]}") + print("ok") + + stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func) + assert returncode == 0, _stderr + assert stdout.strip() == "ok" + + +def test_cancel_pending_tasks_suppresses_destroyed_task_warnings(): + """Verify that cancelling pending tasks before loop.close() suppresses 'Task was destroyed + but it is pending!' warnings (e.g. from socket.io ping tasks).""" + + def test_func(): + import asyncio + + async def long_running(): + await asyncio.sleep(1) # simulates a socket.io ping task + + async def start_background_task(): + asyncio.create_task(long_running()) + await asyncio.to_thread(lambda: None) + raise KeyboardInterrupt + + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(start_background_task()) + except KeyboardInterrupt: + pass + + # Apply the task-cancellation fix + pending = [t for t in asyncio.all_tasks(loop) if not t.done()] + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + + loop.run_until_complete(loop.shutdown_default_executor()) + loop.close() + print("ok") + + stdout, _stderr, returncode = dangerously_run_function_in_subprocess(test_func) + assert returncode == 0, _stderr + assert stdout.strip() == "ok" + # The "Task was destroyed but it is pending!" message appears on stderr when tasks are NOT + # cancelled before loop.close(). After the fix it must be absent. + assert "Task was destroyed but it is pending" not in _stderr From 67669b7fbe1ddd0e018d2f3ccf055b8af8dd4447 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 5 Mar 2026 22:08:09 -0500 Subject: [PATCH 11/24] QoL: Persist selected board and most recent image across browser sessions (#8920) * Persist selected board and auto-select most recent image across browser sessions (#92) * Persist selectedBoardId across browser sessions Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * fix(frontend): make appStarted listener async so image auto-selection works on startup Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(frontend): remove unwanted package-lock.json --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- .../listeners/appStarted.ts | 37 ++++++++++--------- .../features/gallery/store/gallerySlice.ts | 2 +- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index 6bff69c64a..b1d60edc2d 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -12,27 +12,12 @@ export const appStarted = createAction('app/appStarted'); export const addAppStartedListener = (startAppListening: AppStartListening) => { startAppListening({ actionCreator: appStarted, - effect: (action, { unsubscribe, cancelActiveListeners, take, getState, dispatch }) => { + effect: async (action, { unsubscribe, cancelActiveListeners, take, getState, dispatch }) => { // this should only run once cancelActiveListeners(); unsubscribe(); - // ensure an image is selected when we load the first board - take(imagesApi.endpoints.getImageNames.matchFulfilled).then((firstImageLoad) => { - if (firstImageLoad === null) { - // timeout or cancelled - return; - } - const [{ payload }] = firstImageLoad; - const selectedImage = selectLastSelectedItem(getState()); - if (selectedImage) { - return; - } - if (payload.image_names[0]) { - dispatch(imageSelected(payload.image_names[0])); - } - }); - + // Fire patchmatch check without blocking the image-selection logic below dispatch(appInfoApi.endpoints.getPatchmatchStatus.initiate()) .unwrap() .then((isPatchmatchAvailable) => { @@ -43,6 +28,24 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => { } }) .catch(noop); + + // ensure an image is selected when we load the first board. + // The effect must be async and await take() so that RTK keeps the listener's AbortController + // alive until the query resolves; a synchronous effect causes the controller to be aborted + // immediately after the effect returns, before any network response arrives. + const firstImageLoad = await take(imagesApi.endpoints.getImageNames.matchFulfilled, 5000); + if (firstImageLoad === null) { + // timeout or cancelled + return; + } + const [{ payload }] = firstImageLoad; + const selectedImage = selectLastSelectedItem(getState()); + if (selectedImage) { + return; + } + if (payload.image_names[0]) { + dispatch(imageSelected(payload.image_names[0])); + } }, }); }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 9d4d2bfd75..6a25caadce 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -191,6 +191,6 @@ export const gallerySliceConfig: SliceConfig = { } return zGalleryState.parse(state); }, - persistDenylist: ['selection', 'selectedBoardId', 'galleryView', 'imageToCompare'], + persistDenylist: ['selection', 'galleryView', 'imageToCompare'], }, }; From 94e04b1e1e64a924ee1b317209dc3ed15ffb3746 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 5 Mar 2026 22:35:44 -0500 Subject: [PATCH 12/24] Fix race condition in download queue when concurrent jobs share destination directory (#104) (#8931) * Initial plan * Fix race condition in _do_download when scanning for .downloading files * chore(backend): update copyright --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --- .../app/services/download/download_default.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/invokeai/app/services/download/download_default.py b/invokeai/app/services/download/download_default.py index c21ffde5a1..13e86d1828 100644 --- a/invokeai/app/services/download/download_default.py +++ b/invokeai/app/services/download/download_default.py @@ -1,4 +1,4 @@ -# Copyright (c) 2023, Lincoln D. Stein +# Copyright (c) 2023,2026 Lincoln D. Stein """Implementation of multithreaded download queue for invokeai.""" import os @@ -388,18 +388,23 @@ class DownloadQueueService(DownloadQueueServiceBase): if len(candidates) == 1: inferred = candidates[0].with_name(candidates[0].name.removesuffix(".downloading")) job.download_path = inferred - resume_from = candidates[0].stat().st_size - job.bytes = resume_from - self._logger.debug( - f"Resume check (dir): inferred in-progress file path={candidates[0]} size={resume_from} bytes" - ) - if resume_from > 0: - if job.etag: - header["If-Range"] = job.etag - elif job.last_modified: - header["If-Range"] = job.last_modified - header["Range"] = f"bytes={resume_from}-" - open_mode = "ab" + try: + resume_from = candidates[0].stat().st_size + except FileNotFoundError: + # The .downloading file was renamed/deleted between glob and stat (race condition); skip resume. + job.download_path = None + else: + job.bytes = resume_from + self._logger.debug( + f"Resume check (dir): inferred in-progress file path={candidates[0]} size={resume_from} bytes" + ) + if resume_from > 0: + if job.etag: + header["If-Range"] = job.etag + elif job.last_modified: + header["If-Range"] = job.last_modified + header["Range"] = f"bytes={resume_from}-" + open_mode = "ab" else: self._logger.debug( "Resume check (dir): no prior download_path available; cannot resume from disk " From fcdcd7f46b6f867be2673ff37725fca1dae0ee3c Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Fri, 6 Mar 2026 18:13:30 -0700 Subject: [PATCH 13/24] Prompt Attention Fixes (#8860) * fix(prompt): add more punctuations, fixes attention hotkeys removing them from prompt. * fix(prompt): improve numeric weighting calculation * feat(prompts): add numeric attention preference toggle to settings * feat(prompts): use attention style preference, rewrite to accomodate prompt functions * fix(prompts): account for weirdness with quotes account for mismatching quotes, missing quotes and other quote entities * fix(prompts): add tests, qol improvements, code cleanup * fix(prompts): test lint * fix(prompts): remove unused exports * fix(prompts): separator whitespace serialization --------- Co-authored-by: joshistoast Co-authored-by: Lincoln Stein --- invokeai/frontend/web/public/locales/en.json | 2 + .../web/src/common/util/promptAST.test.ts | 501 +++++++++++ .../frontend/web/src/common/util/promptAST.ts | 661 ++++++++++++-- .../src/common/util/promptAttention.test.ts | 806 ++++++++++++++--- .../web/src/common/util/promptAttention.ts | 826 +++++++++++------- .../prompt/usePromptAttentionHotkeys.ts | 17 +- .../SettingsModal/SettingsModal.tsx | 20 + .../src/features/system/store/systemSlice.ts | 8 + .../web/src/features/system/store/types.ts | 1 + 9 files changed, 2337 insertions(+), 505 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2db971d06a..4fcf0786b8 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1566,6 +1566,8 @@ "general": "General", "generation": "Generation", "models": "Models", + "preferAttentionStyleNumeric": "Prefer Numeric Attention Style", + "prompt": "Prompt", "resetComplete": "Web UI has been reset.", "resetWebUI": "Reset Web UI", "resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.", diff --git a/invokeai/frontend/web/src/common/util/promptAST.test.ts b/invokeai/frontend/web/src/common/util/promptAST.test.ts index 97f82716b8..32ad9dc09f 100644 --- a/invokeai/frontend/web/src/common/util/promptAST.test.ts +++ b/invokeai/frontend/web/src/common/util/promptAST.test.ts @@ -78,6 +78,44 @@ describe('promptAST', () => { { type: 'rembed', start: 15, end: 16 }, ]); }); + + it('should tokenize prompt function syntax', () => { + const tokens = tokenize("('a', 'b').and()"); + expect(tokens).toEqual([ + { type: 'lparen', start: 0, end: 1 }, + { type: 'punct', value: "'", start: 1, end: 2 }, + { type: 'word', value: 'a', start: 2, end: 3 }, + { type: 'punct', value: "'", start: 3, end: 4 }, + { type: 'punct', value: ',', start: 4, end: 5 }, + { type: 'whitespace', value: ' ', start: 5, end: 6 }, + { type: 'punct', value: "'", start: 6, end: 7 }, + { type: 'word', value: 'b', start: 7, end: 8 }, + { type: 'punct', value: "'", start: 8, end: 9 }, + { type: 'rparen', start: 9, end: 10 }, + { type: 'punct', value: '.', start: 10, end: 11 }, + { type: 'word', value: 'and', start: 11, end: 14 }, + { type: 'lparen', start: 14, end: 15 }, + { type: 'rparen', start: 15, end: 16 }, + ]); + }); + + it('should tokenize curly/smart quotes as punctuation', () => { + const tokens = tokenize('\u201chello\u201d'); + expect(tokens).toEqual([ + { type: 'punct', value: '\u201c', start: 0, end: 1 }, + { type: 'word', value: 'hello', start: 1, end: 6 }, + { type: 'punct', value: '\u201d', start: 6, end: 7 }, + ]); + }); + + it('should tokenize curly single quotes as punctuation', () => { + const tokens = tokenize('\u2018hello\u2019'); + expect(tokens).toEqual([ + { type: 'punct', value: '\u2018', start: 0, end: 1 }, + { type: 'word', value: 'hello', start: 1, end: 6 }, + { type: 'punct', value: '\u2019', start: 6, end: 7 }, + ]); + }); }); describe('parseTokens', () => { @@ -167,6 +205,312 @@ describe('promptAST', () => { const ast = parseTokens(tokens); expect(ast).toEqual([{ type: 'embedding', value: 'embedding_name', range: { start: 0, end: 16 } }]); }); + + describe('prompt functions', () => { + it('should parse .and() prompt function with single-quoted args', () => { + const tokens = tokenize("('one two', 'three four').and()"); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('and'); + expect(pf.functionParams).toBe(''); + expect(pf.promptArgs).toHaveLength(2); + + // First arg: 'one two' + expect(pf.promptArgs[0]!.quote).toBe("'"); + expect(pf.promptArgs[0]!.nodes).toHaveLength(3); // word, ws, word + expect(pf.promptArgs[0]!.nodes[0]).toMatchObject({ type: 'word', text: 'one' }); + expect(pf.promptArgs[0]!.nodes[2]).toMatchObject({ type: 'word', text: 'two' }); + + // Second arg: 'three four' + expect(pf.promptArgs[1]!.quote).toBe("'"); + expect(pf.promptArgs[1]!.nodes).toHaveLength(3); + expect(pf.promptArgs[1]!.nodes[0]).toMatchObject({ type: 'word', text: 'three' }); + expect(pf.promptArgs[1]!.nodes[2]).toMatchObject({ type: 'word', text: 'four' }); + }); + + it('should parse .or() prompt function', () => { + const tokens = tokenize("('one', 'two three. four.').or()"); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('or'); + expect(pf.promptArgs).toHaveLength(2); + + // First arg: 'one' + expect(pf.promptArgs[0]!.nodes).toHaveLength(1); + expect(pf.promptArgs[0]!.nodes[0]).toMatchObject({ type: 'word', text: 'one' }); + + // Second arg: 'two three. four.' + expect(pf.promptArgs[1]!.nodes.length).toBeGreaterThanOrEqual(5); + }); + + it('should parse .blend() prompt function with params', () => { + const tokens = tokenize("('one', 'two').blend(0.7, 0.3)"); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('blend'); + expect(pf.functionParams).toBe('0.7, 0.3'); + expect(pf.promptArgs).toHaveLength(2); + }); + + it('should parse prompt function with double-quoted args', () => { + const tokens = tokenize('("one", "two").and()'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('and'); + expect(pf.promptArgs[0]!.quote).toBe('"'); + }); + + it('should parse prompt function with curly double quotes', () => { + const tokens = tokenize('(\u201cone\u201d, \u201ctwo\u201d).and()'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('and'); + expect(pf.promptArgs).toHaveLength(2); + expect(pf.promptArgs[0]!.quote).toBe('\u201c'); + expect(pf.promptArgs[0]!.nodes[0]).toMatchObject({ type: 'word', text: 'one' }); + expect(pf.promptArgs[1]!.nodes[0]).toMatchObject({ type: 'word', text: 'two' }); + }); + + it('should parse prompt function with curly single quotes', () => { + const tokens = tokenize('(\u2018one\u2019, \u2018two\u2019).and()'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('and'); + expect(pf.promptArgs[0]!.quote).toBe('\u2018'); + }); + + it('should parse prompt function with curly quotes containing commas in args', () => { + const prompt = '(\u201chigh detail, cinematic\u201d, \u201csoft light, portrait\u201d).and()'; + const ast = parseTokens(tokenize(prompt)); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.promptArgs).toHaveLength(2); + }); + + it('should parse prompt function with newline before .method()', () => { + const prompt = '(\u201cone\u201d, \u201ctwo\u201d)\n.and()'; + const ast = parseTokens(tokenize(prompt)); + expect(ast).toHaveLength(1); + expect(ast[0]!.type).toBe('prompt_function'); + }); + + it('should parse quoted prompt function with newline before .method()', () => { + const prompt = "('one', 'two')\n.and()"; + const ast = parseTokens(tokenize(prompt)); + expect(ast).toHaveLength(1); + expect(ast[0]!.type).toBe('prompt_function'); + }); + + it('should parse prompt function with attention inside args', () => { + const tokens = tokenize("('hello+', '(world)-').and()"); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + + // First arg: hello+ + const arg0Word = pf.promptArgs[0]!.nodes[0]!; + expect(arg0Word).toMatchObject({ type: 'word', text: 'hello', attention: '+' }); + + // Second arg: (world)- + const arg1Group = pf.promptArgs[1]!.nodes[0]!; + expect(arg1Group.type).toBe('group'); + if (arg1Group.type === 'group') { + expect(arg1Group.attention).toBe('-'); + } + }); + + it('should preserve content range for each arg', () => { + const tokens = tokenize("('one two', 'three four').and()"); + const ast = parseTokens(tokens); + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + + // 'one two' content is between quotes at positions 1 and 9 + expect(pf.promptArgs[0]!.contentRange.start).toBe(2); + expect(pf.promptArgs[0]!.contentRange.end).toBe(9); + + // 'three four' content is between quotes at positions 12 and 23 + expect(pf.promptArgs[1]!.contentRange.start).toBe(13); + expect(pf.promptArgs[1]!.contentRange.end).toBe(23); + }); + + it('should parse prompt function embedded in larger prompt', () => { + const tokens = tokenize("some text, ('a', 'b').and(), more text"); + const ast = parseTokens(tokens); + + // Should have: word, ws, word, punct, ws, prompt_function, punct, ws, word, ws, word + const pfNodes = ast.filter((n) => n.type === 'prompt_function'); + expect(pfNodes).toHaveLength(1); + expect(pfNodes[0]!.type).toBe('prompt_function'); + }); + + it('should fall back to regular group when no method call follows', () => { + const tokens = tokenize("('a', 'b')"); + const ast = parseTokens(tokens); + + // Without .method(), this should be parsed as a regular group + expect(ast[0]!.type).toBe('group'); + }); + + it('should parse three-arg prompt function', () => { + const tokens = tokenize("('a', 'b', 'c').blend(0.5, 0.3, 0.2)"); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.promptArgs).toHaveLength(3); + expect(pf.functionParams).toBe('0.5, 0.3, 0.2'); + }); + }); + + describe('unquoted prompt functions', () => { + it('should parse unquoted .and() prompt function', () => { + const tokens = tokenize('(one,two).and()'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('and'); + expect(pf.functionParams).toBe(''); + expect(pf.promptArgs).toHaveLength(2); + expect(pf.promptArgs[0]!.quote).toBe(''); + expect(pf.promptArgs[0]!.nodes[0]).toMatchObject({ type: 'word', text: 'one' }); + expect(pf.promptArgs[1]!.quote).toBe(''); + expect(pf.promptArgs[1]!.nodes[0]).toMatchObject({ type: 'word', text: 'two' }); + }); + + it('should parse unquoted .and() with spaces', () => { + const tokens = tokenize('(one two, three four).and()'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('and'); + expect(pf.promptArgs).toHaveLength(2); + expect(pf.promptArgs[0]!.nodes[0]).toMatchObject({ type: 'word', text: 'one' }); + expect(pf.promptArgs[0]!.nodes[2]).toMatchObject({ type: 'word', text: 'two' }); + expect(pf.promptArgs[1]!.nodes[0]).toMatchObject({ type: 'word', text: 'three' }); + expect(pf.promptArgs[1]!.nodes[2]).toMatchObject({ type: 'word', text: 'four' }); + }); + + it('should parse unquoted .blend() with params', () => { + const tokens = tokenize('(one two, three four).blend(0.7, 0.3)'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.name).toBe('blend'); + expect(pf.functionParams).toBe('0.7, 0.3'); + expect(pf.promptArgs).toHaveLength(2); + }); + + it('should parse unquoted three-arg prompt function', () => { + const tokens = tokenize('(a, b, c).blend(0.5, 0.3, 0.2)'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + expect(pf.promptArgs).toHaveLength(3); + expect(pf.functionParams).toBe('0.5, 0.3, 0.2'); + }); + + it('should parse unquoted prompt function with attention inside args', () => { + const tokens = tokenize('(hello+, world).and()'); + const ast = parseTokens(tokens); + expect(ast).toHaveLength(1); + + const pf = ast[0]!; + expect(pf.type).toBe('prompt_function'); + if (pf.type !== 'prompt_function') { + return; + } + const arg0Word = pf.promptArgs[0]!.nodes[0]!; + expect(arg0Word).toMatchObject({ type: 'word', text: 'hello', attention: '+' }); + }); + + it('should fall back to regular group for single-arg unquoted function', () => { + const tokens = tokenize('(hello world).and()'); + const ast = parseTokens(tokens); + // Without a comma, this is not detected as a prompt function + expect(ast[0]!.type).toBe('group'); + }); + + it('should parse unquoted prompt function embedded in larger prompt', () => { + const tokens = tokenize('some text, (a, b).and(), more text'); + const ast = parseTokens(tokens); + const pfNodes = ast.filter((n) => n.type === 'prompt_function'); + expect(pfNodes).toHaveLength(1); + }); + }); }); describe('serialize', () => { @@ -218,6 +562,163 @@ describe('promptAST', () => { const result = serialize(ast); expect(result).toBe(''); }); + + describe('prompt functions', () => { + it('should serialize .and() prompt function', () => { + const tokens = tokenize("('one two', 'three four').and()"); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe("('one two', 'three four').and()"); + }); + + it('should serialize .or() prompt function', () => { + const tokens = tokenize("('one', 'two three. four.').or()"); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe("('one', 'two three. four.').or()"); + }); + + it('should serialize .blend() with params', () => { + const tokens = tokenize("('one', 'two').blend(0.7, 0.3)"); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe("('one', 'two').blend(0.7, 0.3)"); + }); + + it('should serialize prompt function with attention inside args', () => { + const tokens = tokenize("('hello+', '(world)-').and()"); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe("('hello+', '(world)-').and()"); + }); + + it('should serialize prompt function embedded in larger prompt', () => { + const prompt = "some text, ('a', 'b').and(), more text"; + const tokens = tokenize(prompt); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe(prompt); + }); + + it('should serialize three-arg blend', () => { + const tokens = tokenize("('a', 'b', 'c').blend(0.5, 0.3, 0.2)"); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe("('a', 'b', 'c').blend(0.5, 0.3, 0.2)"); + }); + + it('should serialize double-quoted prompt function', () => { + const tokens = tokenize('("one", "two").and()'); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe('("one", "two").and()'); + }); + + it('should serialize curly double-quoted prompt function', () => { + const tokens = tokenize('(\u201cone\u201d, \u201ctwo\u201d).and()'); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe('(\u201cone\u201d, \u201ctwo\u201d).and()'); + }); + + it('should serialize curly single-quoted prompt function', () => { + const tokens = tokenize('(\u2018one\u2019, \u2018two\u2019).and()'); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe('(\u2018one\u2019, \u2018two\u2019).and()'); + }); + }); + + describe('unquoted prompt functions', () => { + it('should serialize unquoted .and()', () => { + const tokens = tokenize('(one two, three four).and()'); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe('(one two, three four).and()'); + }); + + it('should serialize unquoted .blend() with params', () => { + const tokens = tokenize('(one two, three four).blend(0.7, 0.3)'); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe('(one two, three four).blend(0.7, 0.3)'); + }); + + it('should serialize unquoted prompt function embedded in larger prompt', () => { + const prompt = 'some text, (a, b).and(), more text'; + const tokens = tokenize(prompt); + const ast = parseTokens(tokens); + const result = serialize(ast); + expect(result).toBe(prompt); + }); + }); + }); + + describe('round-trip (tokenize → parse → serialize)', () => { + const roundTrip = (prompt: string) => { + const tokens = tokenize(prompt); + const ast = parseTokens(tokens); + return serialize(ast); + }; + + it.each([ + 'a cat', + '(a cat)', + '(a cat)1.2', + 'cat+', + 'cat++', + 'cat-', + '(hello world)+', + '(hello world)++', + '(hello world)-', + '\\(medium\\)', + 'colored pencil \\(medium\\) (enhanced)', + '', + 'portrait \\(realistic\\) (high quality)1.2', + '(masterpiece)1.3, best quality, (high detail)1.2', + "('one two', 'three four').and()", + "('one', 'two three. four.').or()", + "('one', 'two').blend(0.7, 0.3)", + "('hello+', '(world)-').and()", + "some text, ('a', 'b').and(), more text", + "('a', 'b', 'c').blend(0.5, 0.3, 0.2)", + '("one", "two").and()', + // Curly double-quoted prompt functions + '(\u201cone\u201d, \u201ctwo\u201d).and()', + '(\u201chigh detail, cinematic\u201d, \u201csoft light, portrait\u201d).and()', + '(\u201cone\u201d, \u201ctwo\u201d).blend(0.7, 0.3)', + // Curly single-quoted prompt functions + '(\u2018one\u2019, \u2018two\u2019).and()', + '(\u2018one\u2019, \u2018two\u2019).or()', + // Unquoted prompt functions + '(one two, three four).and()', + '(one two, three four).blend(0.7, 0.3)', + '(a, b, c).blend(0.5, 0.3, 0.2)', + 'some text, (a, b).and(), more text', + "('one',\n 'two',\n 'three').and()", + ])('should round-trip: %s', (prompt) => { + expect(roundTrip(prompt)).toBe(prompt); + }); + }); + + describe('newline normalization', () => { + const roundTrip = (prompt: string) => { + const tokens = tokenize(prompt); + const ast = parseTokens(tokens); + return serialize(ast); + }; + + it('should normalize newline before .method() in quoted prompt function', () => { + expect(roundTrip("('one', 'two')\n.and()")).toBe("('one', 'two').and()"); + }); + + it('should normalize newline before .method() in curly-quoted prompt function', () => { + expect(roundTrip('(\u201cone\u201d, \u201ctwo\u201d)\n.and()')).toBe('(\u201cone\u201d, \u201ctwo\u201d).and()'); + }); + + it('should normalize newline before .method() in unquoted prompt function', () => { + expect(roundTrip('(one, two)\n.and()')).toBe('(one, two).and()'); + }); }); describe('compel compatibility examples', () => { diff --git a/invokeai/frontend/web/src/common/util/promptAST.ts b/invokeai/frontend/web/src/common/util/promptAST.ts index f8cf7ca62e..0a1af62122 100644 --- a/invokeai/frontend/web/src/common/util/promptAST.ts +++ b/invokeai/frontend/web/src/common/util/promptAST.ts @@ -3,18 +3,10 @@ */ export type Attention = string | number; -type Word = string; - -type Punct = string; - -type Whitespace = string; - -type Embedding = string; - type Token = - | { type: 'word'; value: Word; start: number; end: number } - | { type: 'whitespace'; value: Whitespace; start: number; end: number } - | { type: 'punct'; value: Punct; start: number; end: number } + | { type: 'word'; value: string; start: number; end: number } + | { type: 'whitespace'; value: string; start: number; end: number } + | { type: 'punct'; value: string; start: number; end: number } | { type: 'lparen'; start: number; end: number } | { type: 'rparen'; start: number; end: number } | { type: 'weight'; value: Attention; start: number; end: number } @@ -22,8 +14,21 @@ type Token = | { type: 'rembed'; start: number; end: number } | { type: 'escaped_paren'; value: '(' | ')'; start: number; end: number }; +/** + * A single argument in a prompt function like .and(), .or(), or .blend(). + * Contains the parsed AST nodes of the argument content and metadata about quoting/range. + */ +export type PromptFunctionArg = { + nodes: ASTNode[]; + quote: string; + /** Range of the content between the quotes (exclusive of quotes themselves) in original prompt coordinates. */ + contentRange: { start: number; end: number }; + /** Raw separator whitespace after the comma before this arg (args[1+] only). */ + separator?: string; +}; + export type ASTNode = - | { type: 'word'; text: Word; attention?: Attention; range: { start: number; end: number }; isSelection?: boolean } + | { type: 'word'; text: string; attention?: Attention; range: { start: number; end: number }; isSelection?: boolean } | { type: 'group'; children: ASTNode[]; @@ -31,20 +36,60 @@ export type ASTNode = range: { start: number; end: number }; isSelection?: boolean; } - | { type: 'embedding'; value: Embedding; range: { start: number; end: number }; isSelection?: boolean } - | { type: 'whitespace'; value: Whitespace; range: { start: number; end: number }; isSelection?: boolean } - | { type: 'punct'; value: Punct; range: { start: number; end: number }; isSelection?: boolean } - | { type: 'escaped_paren'; value: '(' | ')'; range: { start: number; end: number }; isSelection?: boolean }; + | { type: 'embedding'; value: string; range: { start: number; end: number }; isSelection?: boolean } + | { type: 'whitespace'; value: string; range: { start: number; end: number }; isSelection?: boolean } + | { type: 'punct'; value: string; range: { start: number; end: number }; isSelection?: boolean } + | { type: 'escaped_paren'; value: '(' | ')'; range: { start: number; end: number }; isSelection?: boolean } + | { + type: 'prompt_function'; + name: string; + promptArgs: PromptFunctionArg[]; + functionParams: string; + range: { start: number; end: number }; + isSelection?: boolean; + }; const WEIGHT_PATTERN = /^[+-]?(\d+(\.\d+)?|[+-]+)/; const WHITESPACE_PATTERN = /^\s+/; -const PUNCTUATION_PATTERN = /^[.,]/; -const OTHER_PATTERN = /\s/; +const WORD_CHAR_PATTERN = /[a-zA-Z0-9_]/; +// prettier-ignore +const PUNCTUATION_PATTERN = /^[.,/!?;:'"""''\u2018\u2019\u201c\u201d`~@#$%^&*=_|]/; + +/** All characters that can serve as an opening quote in a prompt function argument. */ +const OPEN_QUOTE_CHARS = new Set(["'", '"', '\u2018', '\u201c']); + +/** Map from opening curly quote to the matching closing curly quote. Straight quotes match themselves. */ +const CLOSE_QUOTE_MAP: Record = { + "'": "'", + '"': '"', + '\u2018': '\u2019', // ' → ' + '\u201c': '\u201d', // " → " +}; + +// #region Token Helpers + +/** Get the string value of a token, if it has one. */ +function tokenValue(t: Token | undefined): string | undefined { + if (!t) { + return undefined; + } + if ('value' in t) { + return String(t.value); + } + return undefined; +} + +/** Check if a token is a punct token with a specific value. */ +function isPunctValue(t: Token | undefined, value: string): boolean { + return t?.type === 'punct' && tokenValue(t) === value; +} + +// #region Tokenizer /** - * Convert a prompt string into an AST. + * Convert a prompt string into a token stream. * @param prompt string - * @returns ASTNode[] + * @returns Token[] */ export function tokenize(prompt: string): Token[] { if (!prompt) { @@ -52,7 +97,7 @@ export function tokenize(prompt: string): Token[] { } const len = prompt.length; - let tokens: Token[] = []; + const tokens: Token[] = []; let i = 0; while (i < len) { @@ -69,7 +114,7 @@ export function tokenize(prompt: string): Token[] { tokenizeEmbedding(char, i) || tokenizeWord(prompt, i) || tokenizePunctuation(char, i) || - tokenizeOther(char, i); + tokenizeFallback(char, i); if (result) { if (result.token) { @@ -168,15 +213,15 @@ function tokenizeWord(prompt: string, i: number): TokenizeResult { return null; } - if (/[a-zA-Z0-9_]/.test(char)) { + if (WORD_CHAR_PATTERN.test(char)) { let j = i; - while (j < prompt.length && /[a-zA-Z0-9_]/.test(prompt[j]!)) { + while (j < prompt.length && WORD_CHAR_PATTERN.test(prompt[j]!)) { j++; } const word = prompt.slice(i, j); // Check for weight immediately after word (e.g., "Lorem+", "consectetur-") - const weightMatch = prompt.slice(j).match(/^[+-]?(\d+(\.\d+)?|[+-]+)/); + const weightMatch = prompt.slice(j).match(WEIGHT_PATTERN); if (weightMatch && weightMatch[0]) { const weightEnd = j + weightMatch[0].length; return { @@ -210,17 +255,20 @@ function tokenizeEmbedding(char: string, i: number): TokenizeResult { return null; } -function tokenizeOther(char: string, i: number): TokenizeResult { - // Any other single character punctuation - if (OTHER_PATTERN.test(char)) { - return { - token: { type: 'punct', value: char, start: i, end: i + 1 }, - nextIndex: i + 1, - }; - } - return null; +/** + * Fallback tokenizer for characters not matched by any other tokenizer. + * Emits them as word tokens so they are preserved in the AST rather than silently dropped. + * This handles non-Latin Unicode text (CJK, emoji, etc.) and any other unrecognized characters. + */ +function tokenizeFallback(char: string, i: number): TokenizeResult { + return { + token: { type: 'word', value: char, start: i, end: i + 1 }, + nextIndex: i + 1, + }; } +// #region Parser + /** * Convert tokens into an AST. * @param tokens Token[] @@ -233,10 +281,373 @@ export function parseTokens(tokens: Token[]): ASTNode[] { return tokens[pos]; } + function peekAt(offset: number): Token | undefined { + return tokens[pos + offset]; + } + function consume(): Token | undefined { return tokens[pos++]; } + /** + * Quick lookahead check: does the current lparen (already consumed) start a quoted prompt function? + * A quoted prompt function looks like ('...', '...').method(...) + * We check if the first non-whitespace token after lparen is a quote character. + */ + function isQuotedPromptFunctionAhead(): boolean { + let p = 0; + while (peekAt(p)?.type === 'whitespace') { + p++; + } + const t = peekAt(p); + return t?.type === 'punct' && OPEN_QUOTE_CHARS.has(tokenValue(t)!); + } + + /** + * Lookahead check: does the current lparen (already consumed) start an unquoted prompt function? + * An unquoted prompt function looks like (arg1, arg2).method(...) where args are not quoted. + * We scan forward looking for a comma at the same nesting depth, then rparen followed by .word( + */ + function isUnquotedPromptFunctionAhead(): boolean { + let p = 0; + let depth = 0; + let hasComma = false; + + // Scan forward through tokens to find the matching rparen + while (peekAt(p)) { + const t = peekAt(p)!; + + if (t.type === 'lparen') { + depth++; + } else if (t.type === 'rparen') { + if (depth === 0) { + // Found matching rparen — now check for .methodName( pattern + // (possibly with whitespace between ) and .) + if (!hasComma) { + return false; // No comma means it's just a regular group + } + let next = p + 1; + while (peekAt(next)?.type === 'whitespace') { + next++; + } + return ( + isPunctValue(peekAt(next), '.') && peekAt(next + 1)?.type === 'word' && peekAt(next + 2)?.type === 'lparen' + ); + } + depth--; + } else if (isPunctValue(t, ',') && depth === 0) { + hasComma = true; + } + + p++; + } + return false; + } + + /** + * Parse the `.methodName(params)` suffix that follows the closing rparen of a prompt function. + * Assumes whitespace has already been skipped. Returns null and restores pos if the pattern + * doesn't match. + */ + function tryParseMethodTail(savedPos: number): { name: string; functionParams: string; endPos: number } | null { + // Skip whitespace between ) and .methodName (allows newlines) + while (peek()?.type === 'whitespace') { + consume(); + } + + // Expect .methodName(params) + if (!isPunctValue(peek(), '.')) { + pos = savedPos; + return null; + } + consume(); // consume dot + + if (peek()?.type !== 'word') { + pos = savedPos; + return null; + } + const methodName = tokenValue(consume())!; + + // Expect opening paren for method call + if (peek()?.type !== 'lparen') { + pos = savedPos; + return null; + } + consume(); // consume method open paren + + // Collect method params until closing rparen + let functionParams = ''; + while (pos < tokens.length) { + const t = peek()!; + if (t.type === 'rparen') { + break; + } + const tok = consume()!; + const v = tokenValue(tok); + if (v !== undefined) { + functionParams += v; + } + } + + // Expect closing rparen for method call + if (peek()?.type !== 'rparen') { + pos = savedPos; + return null; + } + const methodCloseParen = consume()!; // consume method close paren + + return { name: methodName, functionParams, endPos: methodCloseParen.end }; + } + + /** + * Try to parse a prompt function starting after the opening lparen. + * Returns the PromptFunctionNode if successful, or null if the pattern doesn't match + * (in which case `pos` is restored to `savedPos`). + */ + function tryParsePromptFunction(lparenToken: Token & { type: 'lparen' }, savedPos: number): ASTNode | null { + const args: PromptFunctionArg[] = []; + let openQuoteChar: string | null = null; + let closeQuoteChar: string | null = null; + let pendingSeparator: string | undefined; + + while (pos < tokens.length) { + // Skip whitespace before arg or closing paren + while (peek()?.type === 'whitespace') { + consume(); + } + + // Check for rparen (end of prompt function args) + if (peek()?.type === 'rparen') { + break; + } + + // Expect comma separator between args + if (args.length > 0) { + if (isPunctValue(peek(), ',')) { + consume(); + let sep = ''; + while (peek()?.type === 'whitespace') { + const sepToken = consume()!; + const sepValue = tokenValue(sepToken); + if (sepValue !== undefined) { + sep += sepValue; + } + } + pendingSeparator = sep; + } else { + pos = savedPos; + return null; + } + } + + // Expect opening quote + const openQuoteTok = peek(); + if (!openQuoteTok || openQuoteTok.type !== 'punct') { + pos = savedPos; + return null; + } + const thisOpenQuote = tokenValue(openQuoteTok)!; + if (!OPEN_QUOTE_CHARS.has(thisOpenQuote)) { + pos = savedPos; + return null; + } + + const thisCloseQuote = CLOSE_QUOTE_MAP[thisOpenQuote]!; + if (openQuoteChar === null) { + openQuoteChar = thisOpenQuote; + closeQuoteChar = thisCloseQuote; + } else if (thisOpenQuote !== openQuoteChar) { + // Mismatched quote style between args + pos = savedPos; + return null; + } + + consume(); // consume opening quote + const contentStart = openQuoteTok.end; + + // Collect tokens until closing quote + const argTokens: Token[] = []; + let contentEnd = contentStart; + while (pos < tokens.length) { + const t = peek(); + if (isPunctValue(t, closeQuoteChar!)) { + contentEnd = t!.start; + break; + } + const consumed = consume()!; + argTokens.push(consumed); + contentEnd = consumed.end; + } + + // Expect closing quote + if (!isPunctValue(peek(), closeQuoteChar!)) { + pos = savedPos; + return null; + } + consume(); // consume closing quote + + // Parse sub-tokens as AST + const argNodes = parseTokens(argTokens); + + args.push({ + nodes: argNodes, + quote: openQuoteChar, + contentRange: { start: contentStart, end: contentEnd }, + separator: pendingSeparator, + }); + pendingSeparator = undefined; + } + + if (args.length === 0) { + pos = savedPos; + return null; + } + + // Expect rparen + if (peek()?.type !== 'rparen') { + pos = savedPos; + return null; + } + consume(); // consume rparen + + // Parse .methodName(params) suffix + const methodTail = tryParseMethodTail(savedPos); + if (!methodTail) { + return null; // pos already restored by tryParseMethodTail + } + + return { + type: 'prompt_function', + name: methodTail.name, + promptArgs: args, + functionParams: methodTail.functionParams, + range: { start: lparenToken.start, end: methodTail.endPos }, + }; + } + + /** + * Try to parse an unquoted prompt function starting after the opening lparen. + * Unquoted prompt functions look like (arg1 words, arg2 words).method(params) + * where arguments are separated by commas without quotes. + * Returns the PromptFunctionNode if successful, or null if the pattern doesn't match + * (in which case `pos` is restored to `savedPos`). + */ + function tryParseUnquotedPromptFunction(lparenToken: Token & { type: 'lparen' }, savedPos: number): ASTNode | null { + const args: PromptFunctionArg[] = []; + let pendingSeparator: string | undefined; + + while (pos < tokens.length) { + // Check for rparen (end of prompt function args) + if (peek()?.type === 'rparen') { + break; + } + + // Expect comma separator between args (consume the comma) + if (args.length > 0) { + if (isPunctValue(peek(), ',')) { + consume(); // consume comma + let sep = ''; + while (peek()?.type === 'whitespace') { + const sepToken = consume()!; + const sepValue = tokenValue(sepToken); + if (sepValue !== undefined) { + sep += sepValue; + } + } + pendingSeparator = sep; + } else { + pos = savedPos; + return null; + } + } + + // Collect tokens until comma or rparen (at nesting depth 0) + const argTokens: Token[] = []; + let contentStart: number | null = null; + let contentEnd: number | null = null; + let depth = 0; + + while (pos < tokens.length) { + const t = peek()!; + + if (t.type === 'lparen') { + depth++; + } else if (t.type === 'rparen') { + if (depth === 0) { + break; // End of all args + } + depth--; + } else if (isPunctValue(t, ',') && depth === 0) { + break; // End of this arg + } + + if (contentStart === null) { + contentStart = t.start; + } + const consumed = consume()!; + argTokens.push(consumed); + contentEnd = consumed.end; + } + + if (argTokens.length === 0) { + pos = savedPos; + return null; + } + + // Trim leading/trailing whitespace tokens from the arg content + let firstNonWs = 0; + while (firstNonWs < argTokens.length && argTokens[firstNonWs]!.type === 'whitespace') { + firstNonWs++; + } + let lastNonWs = argTokens.length - 1; + while (lastNonWs >= 0 && argTokens[lastNonWs]!.type === 'whitespace') { + lastNonWs--; + } + + const trimmedArgTokens = argTokens.slice(firstNonWs, lastNonWs + 1); + const trimmedStart = trimmedArgTokens.length > 0 ? trimmedArgTokens[0]!.start : contentStart!; + const trimmedEnd = trimmedArgTokens.length > 0 ? trimmedArgTokens[trimmedArgTokens.length - 1]!.end : contentEnd!; + + // Parse sub-tokens as AST + const argNodes = parseTokens(trimmedArgTokens); + + args.push({ + nodes: argNodes, + quote: '', // Unquoted + contentRange: { start: trimmedStart, end: trimmedEnd }, + separator: pendingSeparator, + }); + pendingSeparator = undefined; + } + + if (args.length < 2) { + // An unquoted prompt function must have at least 2 args (otherwise it's a regular group) + pos = savedPos; + return null; + } + + // Expect rparen + if (peek()?.type !== 'rparen') { + pos = savedPos; + return null; + } + consume(); // consume rparen + + // Parse .methodName(params) suffix + const methodTail = tryParseMethodTail(savedPos); + if (!methodTail) { + return null; // pos already restored by tryParseMethodTail + } + + return { + type: 'prompt_function', + name: methodTail.name, + promptArgs: args, + functionParams: methodTail.functionParams, + range: { start: lparenToken.start, end: methodTail.endPos }, + }; + } + function parseGroup(): ASTNode[] { const nodes: ASTNode[] = []; @@ -254,6 +665,30 @@ export function parseTokens(tokens: Token[]): ASTNode[] { } case 'lparen': { const lparen = consume() as Token & { type: 'lparen' }; + + // Try to parse as a quoted prompt function first + if (isQuotedPromptFunctionAhead()) { + const savedPos = pos; + const pfResult = tryParsePromptFunction(lparen, savedPos); + if (pfResult) { + nodes.push(pfResult); + break; + } + // pos was restored by tryParsePromptFunction on failure + } + + // Try to parse as an unquoted prompt function + if (isUnquotedPromptFunctionAhead()) { + const savedPos = pos; + const pfResult = tryParseUnquotedPromptFunction(lparen, savedPos); + if (pfResult) { + nodes.push(pfResult); + break; + } + // pos was restored by tryParseUnquotedPromptFunction on failure + } + + // Regular group parsing const groupChildren = parseGroup(); let attention: Attention | undefined; @@ -283,10 +718,10 @@ export function parseTokens(tokens: Token[]): ASTNode[] { let end = lembed.end; while (peek() && peek()!.type !== 'rembed') { const embedToken = consume()!; - embedValue += - embedToken.type === 'word' || embedToken.type === 'punct' || embedToken.type === 'whitespace' - ? embedToken.value - : ''; + const v = tokenValue(embedToken); + if (v !== undefined) { + embedValue += v; + } end = embedToken.end; } if (peek()?.type === 'rembed') { @@ -341,47 +776,131 @@ export function parseTokens(tokens: Token[]): ASTNode[] { return parseGroup(); } +// #region Serialization + +/** + * Visitor callbacks for AST serialization. All callbacks are optional. + * Called during traversal to allow tracking node positions in the output string. + */ +type SerializeVisitor = { + /** Called after a node has been fully serialized, with its start and end positions in the output. */ + onNode?: (node: ASTNode, start: number, end: number) => void; +}; + +/** Mutable buffer used by serializeCore so all recursive calls share the same position tracking. */ +type SerializeBuffer = { prompt: string }; + +/** + * Shared serialization core. Converts an AST back into a prompt string, + * optionally calling visitor hooks for position tracking. + * + * Uses a shared mutable buffer so that node positions reported via + * `visitor.onNode` are always absolute offsets in the final output string, + * even for nodes nested inside groups or prompt function args. + */ +function serializeCore(ast: ASTNode[], visitor: SerializeVisitor | undefined, buf: SerializeBuffer): void { + for (const node of ast) { + const nodeStart = buf.prompt.length; + + switch (node.type) { + case 'punct': + case 'whitespace': { + buf.prompt += node.value; + break; + } + case 'escaped_paren': { + buf.prompt += `\\${node.value}`; + break; + } + case 'word': { + buf.prompt += node.text; + if (node.attention) { + buf.prompt += String(node.attention); + } + break; + } + case 'group': { + buf.prompt += '('; + serializeCore(node.children, visitor, buf); + buf.prompt += ')'; + if (node.attention) { + buf.prompt += String(node.attention); + } + break; + } + case 'embedding': { + buf.prompt += `<${node.value}>`; + break; + } + case 'prompt_function': { + buf.prompt += '('; + for (let i = 0; i < node.promptArgs.length; i++) { + if (i > 0) { + const sep = node.promptArgs[i]!.separator ?? ' '; + buf.prompt += `,${sep}`; + } + const arg = node.promptArgs[i]!; + buf.prompt += arg.quote; + serializeCore(arg.nodes, visitor, buf); + buf.prompt += CLOSE_QUOTE_MAP[arg.quote] ?? arg.quote; + } + buf.prompt += ').'; + buf.prompt += node.name; + buf.prompt += '('; + buf.prompt += node.functionParams; + buf.prompt += ')'; + break; + } + } + + visitor?.onNode?.(node, nodeStart, buf.prompt.length); + } +} + /** * Convert an AST back into a prompt string. * @param ast ASTNode[] * @returns string */ export function serialize(ast: ASTNode[]): string { - let prompt = ''; + const buf: SerializeBuffer = { prompt: '' }; + serializeCore(ast, undefined, buf); + return buf.prompt; +} - for (const node of ast) { - switch (node.type) { - case 'punct': - case 'whitespace': { - prompt += node.value; - break; - } - case 'escaped_paren': { - prompt += `\\${node.value}`; - break; - } - case 'word': { - prompt += node.text; - if (node.attention) { - prompt += String(node.attention); +/** + * Serialize an AST to a prompt string while simultaneously computing the + * selection range from `isSelection` flags on nodes. + * + * This is more reliable than separate serialize + selection computation because + * the position tracking is guaranteed to match the serialized output. + */ +export function serializeWithSelection(ast: ASTNode[]): { + prompt: string; + selectionStart: number; + selectionEnd: number; +} { + let selStart = Infinity; + let selEnd = -1; + + const buf: SerializeBuffer = { prompt: '' }; + serializeCore( + ast, + { + onNode(node, start, end) { + if (node.isSelection) { + selStart = Math.min(selStart, start); + selEnd = Math.max(selEnd, end); } - break; - } - case 'group': { - prompt += '('; - prompt += serialize(node.children); - prompt += ')'; - if (node.attention) { - prompt += String(node.attention); - } - break; - } - case 'embedding': { - prompt += `<${node.value}>`; - break; - } - } + }, + }, + buf + ); + + if (selStart === Infinity) { + selStart = 0; + selEnd = buf.prompt.length; } - return prompt; + return { prompt: buf.prompt, selectionStart: selStart, selectionEnd: selEnd }; } diff --git a/invokeai/frontend/web/src/common/util/promptAttention.test.ts b/invokeai/frontend/web/src/common/util/promptAttention.test.ts index ce3e8208ee..6e165872ec 100644 --- a/invokeai/frontend/web/src/common/util/promptAttention.test.ts +++ b/invokeai/frontend/web/src/common/util/promptAttention.test.ts @@ -2,170 +2,706 @@ import { describe, expect, it } from 'vitest'; import { adjustPromptAttention } from './promptAttention'; +/** + * Helper: select by substring match within the prompt. + * If `selected` is a string, finds it in the prompt and uses its position. + * If `selected` is a [start, end] tuple, uses those positions directly. + */ +function adj( + prompt: string, + selected: string | [number, number], + direction: 'increment' | 'decrement', + prefersNumericWeights = false +) { + const [start, end] = + typeof selected === 'string' ? [prompt.indexOf(selected), prompt.indexOf(selected) + selected.length] : selected; + return adjustPromptAttention(prompt, start, end, direction, prefersNumericWeights); +} + +/** Helper that calls adj with prefersNumericWeights=true */ +function adjNumeric(prompt: string, selected: string | [number, number], direction: 'increment' | 'decrement') { + return adj(prompt, selected, direction, true); +} + describe('adjustPromptAttention', () => { - describe('cross-boundary selection', () => { - it('should split group and apply attention when selection spans from inside group to outside (increment)', () => { - const prompt = '(a b)+ c'; - const result = adjustPromptAttention(prompt, 3, 8, 'increment'); - - expect(result.prompt).toBe('(a b+ c)+'); - }); - - it('should split group and apply attention when selection spans from inside group to outside (decrement)', () => { - const prompt = '(a b)+ c'; - const result = adjustPromptAttention(prompt, 3, 8, 'decrement'); - - expect(result.prompt).toBe('a+ b c-'); - }); - - it('should split group when selection starts before group and ends inside (increment)', () => { - const prompt = 'a (b c)+'; - const result = adjustPromptAttention(prompt, 0, 4, 'increment'); - - expect(result.prompt).toBe('(a b+ c)+'); - }); - - it('should split group when selection starts before group and ends inside (decrement)', () => { - const prompt = 'a (b c)+'; - const result = adjustPromptAttention(prompt, 0, 4, 'decrement'); - - expect(result.prompt).toBe('a- b c+'); - }); - - it('should handle nested groups with cross-boundary selection (increment)', () => { - const prompt = '((a b)+)+ c'; - const result = adjustPromptAttention(prompt, 2, 11, 'increment'); - - expect(result.prompt).toBe('((a b)++ c)+'); - }); - - it('should handle nested groups with cross-boundary selection (decrement)', () => { - const prompt = '((a b)+)+ c'; - const result = adjustPromptAttention(prompt, 2, 11, 'decrement'); - - expect(result.prompt).toBe('(a b)+ c-'); - }); - - it('should handle selection spanning multiple groups (increment)', () => { - const prompt = '(a)+ (b)+'; - const result = adjustPromptAttention(prompt, 0, 9, 'increment'); - - expect(result.prompt).toBe('(a b)++'); - }); - - it('should handle selection spanning multiple groups (decrement)', () => { - const prompt = '(a)+ (b)+'; - const result = adjustPromptAttention(prompt, 0, 9, 'decrement'); - - expect(result.prompt).toBe('a b'); - }); - - it('should split negative group correctly (decrement on negative group)', () => { - const prompt = '(a b)- c'; - const result = adjustPromptAttention(prompt, 3, 8, 'decrement'); - - expect(result.prompt).toBe('(a b- c)-'); - }); - - it('should split negative group correctly (increment on negative group)', () => { - const prompt = '(a b)- c'; - const result = adjustPromptAttention(prompt, 3, 8, 'increment'); - - expect(result.prompt).toBe('a- b c+'); - }); - - it('should handle multiple non-selected items in group', () => { - const prompt = '(a b c)+ d'; - const result = adjustPromptAttention(prompt, 5, 10, 'decrement'); - - expect(result.prompt).toBe('(a b)+ c d-'); - }); - - it('should handle word with existing attention in group when crossing boundary', () => { - const prompt = 'c (d- e)+'; - const result = adjustPromptAttention(prompt, 0, 5, 'increment'); - - expect(result.prompt).toBe('c+ d e+'); - }); - - it('should handle complex multi-group case', () => { - const prompt = '(a+ b)+ c (d- e)+'; - const result = adjustPromptAttention(prompt, 8, 14, 'increment'); - - expect(result.prompt).toBe('(a+ b c)+ d e+'); - }); - }); + // Basic Attention describe('single word', () => { - it('should add + when incrementing word without attention', () => { - const prompt = 'hello world'; - const result = adjustPromptAttention(prompt, 0, 5, 'increment'); - - expect(result.prompt).toBe('hello+ world'); - }); - - it('should add - when decrementing word without attention', () => { - const prompt = 'hello world'; - const result = adjustPromptAttention(prompt, 0, 5, 'decrement'); - - expect(result.prompt).toBe('hello- world'); + it.each([ + ['hello world', 'hello', 'increment', 'hello+ world'], + ['hello world', 'hello', 'decrement', 'hello- world'], + ['hello+ world', 'hello+', 'increment', 'hello++ world'], + ['hello+ world', 'hello+', 'decrement', 'hello world'], + ['hello- world', 'hello-', 'decrement', 'hello-- world'], + ['hello- world', 'hello-', 'increment', 'hello world'], + ] as const)('%s [%s] %s → %s', (prompt, selected, direction, expected) => { + expect(adj(prompt, selected, direction).prompt).toBe(expected); }); }); - describe('existing group', () => { - it('should adjust group attention when cursor is at group boundary', () => { - const prompt = '(hello world)+'; - const result = adjustPromptAttention(prompt, 13, 14, 'increment'); + describe('multiple words', () => { + it.each([ + ['hello world', [0, 11] as [number, number], 'increment', '(hello world)+'], + ['hello world', [0, 11] as [number, number], 'decrement', '(hello world)-'], + ] as const)('%s [%s] %s → %s', (prompt, selected, direction, expected) => { + expect(adj(prompt, selected, direction).prompt).toBe(expected); + }); + }); - expect(result.prompt).toBe('(hello world)++'); + describe('cursor at word-punctuation boundary', () => { + it('should select word, not punctuation, when cursor is between word and comma', () => { + // "one|, two" — cursor at position 3, between "one" (0-3) and "," (3-4) + expect(adj('one, two', [3, 3], 'increment').prompt).toBe('one+, two'); + }); + + it('should select word, not punctuation, when cursor is between word and period', () => { + expect(adj('one. two', [3, 3], 'increment').prompt).toBe('one+. two'); + }); + + it('should select word when cursor is at start of word after punctuation', () => { + // "one, |two" — cursor at position 5, between " " (4-5) and "two" (5-8) + expect(adj('one, two', [5, 5], 'increment').prompt).toBe('one, two+'); + }); + + it('should still select punctuation when cursor is only touching punctuation', () => { + // Cursor in the middle of a run of punctuation with no adjacent word + // e.g. "one ,, two" cursor at position 5 — between "," (4-5) and "," (5-6) + // Both neighbors are punct, so no word to prefer — should still work + const result = adj('one ,, two', [5, 5], 'increment'); + expect(result).toBeDefined(); + }); + }); + + // Existing Groups + + describe('existing groups', () => { + it('should increment group when cursor is at group boundary', () => { + expect(adj('(hello world)+', [13, 14], 'increment').prompt).toBe('(hello world)++'); }); it('should remove group when attention becomes neutral', () => { - const prompt = '(hello world)+'; - const result = adjustPromptAttention(prompt, 0, 14, 'decrement'); + expect(adj('(hello world)+', [0, 14], 'decrement').prompt).toBe('hello world'); + }); - expect(result.prompt).toBe('hello world'); + it('should increment inner word within group', () => { + const result = adj('(a b)+', [1, 2], 'increment'); + expect(result.prompt).toBe('(a+ b)+'); }); }); - describe('multiple words without group', () => { - it('should create new group with + when incrementing multiple words', () => { - const prompt = 'hello world'; - const result = adjustPromptAttention(prompt, 0, 11, 'increment'); + // Cross-Boundary Selection - expect(result.prompt).toBe('(hello world)+'); - }); - - it('should create new group with - when decrementing multiple words', () => { - const prompt = 'hello world'; - const result = adjustPromptAttention(prompt, 0, 11, 'decrement'); - - expect(result.prompt).toBe('(hello world)-'); + describe('cross-boundary selection', () => { + it.each([ + // Selection from inside group to outside + ['(a b)+ c', [3, 8], 'increment', '(a b+ c)+'], + ['(a b)+ c', [3, 8], 'decrement', 'a+ b c-'], + // Selection from outside to inside group + ['a (b c)+', [0, 4], 'increment', '(a b+ c)+'], + ['a (b c)+', [0, 4], 'decrement', 'a- b c+'], + // Nested groups + ['((a b)+)+ c', [2, 11], 'increment', '((a b)++ c)+'], + ['((a b)+)+ c', [2, 11], 'decrement', '(a b)+ c-'], + // Spanning multiple groups + ['(a)+ (b)+', [0, 9], 'increment', '(a b)++'], + ['(a)+ (b)+', [0, 9], 'decrement', 'a b'], + // Negative groups + ['(a b)- c', [3, 8], 'decrement', '(a b- c)-'], + ['(a b)- c', [3, 8], 'increment', 'a- b c+'], + // Multiple non-selected items in group + ['(a b c)+ d', [5, 10], 'decrement', '(a b)+ c d-'], + // Word with existing attention crossing boundary + ['c (d- e)+', [0, 5], 'increment', 'c+ d e+'], + // Complex multi-group + ['(a+ b)+ c (d- e)+', [8, 14], 'increment', '(a+ b c)+ d e+'], + ] as const)('%s [%s] %s → %s', (prompt, selected, direction, expected) => { + expect(adj(prompt, selected as string | [number, number], direction).prompt).toBe(expected); }); }); + // Selection Preservation + describe('selection preservation', () => { - it('should preserve selection when incrementing single word', () => { - const prompt = 'hello world'; - const result = adjustPromptAttention(prompt, 0, 5, 'increment'); + it('should track selection when incrementing single word', () => { + const result = adj('hello world', 'hello', 'increment'); expect(result.prompt).toBe('hello+ world'); expect(result.prompt.slice(result.selectionStart, result.selectionEnd)).toBe('hello+'); }); - it('should preserve selection when incrementing group', () => { - const prompt = '(hello world)+'; - const result = adjustPromptAttention(prompt, 0, 14, 'increment'); + it('should track selection when incrementing full group', () => { + const result = adj('(hello world)+', [0, 14], 'increment'); expect(result.prompt).toBe('(hello world)++'); expect(result.prompt.slice(result.selectionStart, result.selectionEnd)).toBe('(hello world)++'); }); - it('should preserve selection when splitting group', () => { - const prompt = '(a b)+'; - const result = adjustPromptAttention(prompt, 1, 2, 'increment'); // Select 'a' (index 1 to 2) - // 'a' becomes 1.21, 'b' stays 1.1 - // Result: (a+ b)+ which is equivalent to a++ b+ + it('should track selection when splitting group', () => { + const result = adj('(a b)+', [1, 2], 'increment'); expect(result.prompt).toBe('(a+ b)+'); expect(result.prompt.slice(result.selectionStart, result.selectionEnd)).toBe('a+'); }); }); + + // Numeric Attention Weights + + describe('numeric attention weights', () => { + it.each([ + // Increment / decrement numeric weights with additive step + ['(masterpiece)1.3', [0, 16], 'increment', '(masterpiece)1.4'], + ['(masterpiece)1.3', [0, 16], 'decrement', '(masterpiece)1.2'], + ['(high detail)1.2', [0, 16], 'increment', '(high detail)1.3'], + ['(sunny midday light)1.15', [0, 24], 'increment', '(sunny midday light)1.25'], + ['(sunny midday light)1.15', [0, 24], 'decrement', '(sunny midday light)1.05'], + ] as const)('%s [%s] %s → %s', (prompt, selected, direction, expected) => { + expect(adj(prompt, selected as [number, number], direction).prompt).toBe(expected); + }); + + it('should preserve non-selected numeric weights when adjusting elsewhere', () => { + const prompt = '(masterpiece)1.3, best quality'; + const result = adj(prompt, 'best quality', 'increment'); + expect(result.prompt).toContain('(masterpiece)1.3'); + expect(result.prompt).not.toContain('masterpiece1.3'); + }); + + it('should not produce floating point garbage', () => { + const prompt = '(high detail)1.2, oil painting'; + const result = adj(prompt, 'oil painting', 'increment'); + expect(result.prompt).toContain('(high detail)1.2'); + expect(result.prompt).not.toMatch(/1\.19999/); + expect(result.prompt).not.toMatch(/1\.20000/); + }); + + it('should preserve numeric weight 1.15 without corruption', () => { + const prompt = '(sunny midday light)1.15, landscape'; + const result = adj(prompt, 'landscape', 'increment'); + expect(result.prompt).toContain('(sunny midday light)1.15'); + expect(result.prompt).not.toMatch(/1\.15005/); + }); + + it('should normalize numeric 1.1 weight to + syntax', () => { + const prompt = '(lush rolling hills)1.1, landscape'; + const result = adj(prompt, 'landscape', 'increment'); + expect(result.prompt).toMatch(/\(lush rolling hills\)(\+|1\.1)/); + }); + + it('should handle the full complex prompt without corrupting non-selected weights', () => { + const prompt = + '(masterpiece)1.3, best quality, (high detail)1.2, oil painting, (sunny midday light)1.15, an old stone castle standing on a hill, medieval architecture, weathered stone walls, (lush rolling hills)1.1, expansive landscape, clear blue sky'; + const result = adj(prompt, 'clear blue sky', 'increment'); + + expect(result.prompt).toContain('(masterpiece)1.3'); + expect(result.prompt).toContain('(high detail)1.2'); + expect(result.prompt).toContain('(sunny midday light)1.15'); + expect(result.prompt).toContain('(clear blue sky)+'); + expect(result.prompt).not.toMatch(/\d\.\d{5,}/); + }); + }); + + // Prompt Functions + + describe('prompt functions', () => { + describe('within a single argument', () => { + it.each([ + // Single word inside an arg + ["('hello world', 'other').and()", 'hello', 'increment', "('hello+ world', 'other').and()"], + ["('hello world', 'other').and()", 'hello', 'decrement', "('hello- world', 'other').and()"], + // Multiple words in second arg + ["('a', 'hello world').or()", 'hello world', 'increment', "('a', '(hello world)+').or()"], + ["('a', 'hello world').or()", 'hello world', 'decrement', "('a', '(hello world)-').or()"], + // Single word in .blend() + ["('one two', 'three four').blend(0.7, 0.3)", 'two', 'increment', "('one two+', 'three four').blend(0.7, 0.3)"], + ] as const)('%s [%s] %s → %s', (prompt, selected, direction, expected) => { + expect(adj(prompt, selected, direction).prompt).toBe(expected); + }); + }); + + describe('across argument separator', () => { + it('should adjust both args simultaneously when selection spans separator (increment)', () => { + const prompt = "('one two', 'three four').and()"; + // Select across the separator: "two', 'three" + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('one two+', 'three+ four').and()"); + }); + + it('should adjust both args simultaneously when selection spans separator (decrement)', () => { + const prompt = "('one two', 'three four').and()"; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'decrement'); + expect(result.prompt).toBe("('one two-', 'three- four').and()"); + }); + + it('should adjust across separator for .or()', () => { + const prompt = "('alpha beta', 'gamma delta').or()"; + const start = prompt.indexOf('beta'); + const end = prompt.indexOf('gamma') + 'gamma'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('alpha beta+', 'gamma+ delta').or()"); + }); + + it('should adjust across separator for .blend() preserving params', () => { + const prompt = "('one two', 'three four').blend(0.7, 0.3)"; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('one two+', 'three+ four').blend(0.7, 0.3)"); + }); + + it('should handle repeated increment across separator', () => { + const prompt = "('one two+', 'three+ four').and()"; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + // "two+" is at the boundary, "three+" is at the boundary + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('one two++', 'three++ four').and()"); + }); + }); + + describe('whole function selected', () => { + it('should increment all content in all args when whole function is selected', () => { + const prompt = "('one', 'two').and()"; + const result = adjustPromptAttention(prompt, 0, prompt.length, 'increment'); + expect(result.prompt).toBe("('one+', 'two+').and()"); + }); + + it('should decrement all content in all args', () => { + const prompt = "('one', 'two').and()"; + const result = adjustPromptAttention(prompt, 0, prompt.length, 'decrement'); + expect(result.prompt).toBe("('one-', 'two-').and()"); + }); + + it('should increment all args of .blend() preserving params', () => { + const prompt = "('one', 'two').blend(0.7, 0.3)"; + const result = adjustPromptAttention(prompt, 0, prompt.length, 'increment'); + expect(result.prompt).toBe("('one+', 'two+').blend(0.7, 0.3)"); + }); + }); + + describe('prompt function embedded in larger prompt', () => { + it('should adjust only the targeted region outside the function', () => { + const prompt = "some text, ('a', 'b').and(), more text"; + const result = adj(prompt, 'some', 'increment'); + expect(result.prompt).toContain('some+'); + expect(result.prompt).toContain("('a', 'b').and()"); + }); + + it('should adjust only the targeted region inside the function', () => { + const prompt = "prefix ('alpha beta', 'gamma').and() suffix"; + const result = adj(prompt, 'alpha', 'increment'); + expect(result.prompt).toContain("'alpha+ beta'"); + expect(result.prompt).toContain('prefix'); + expect(result.prompt).toContain('suffix'); + }); + + it('should adjust text outside and inside function when selection spans boundary', () => { + const prompt = "text ('one two', 'three').and()"; + // Select from 'text' through 'one' + const start = prompt.indexOf('text'); + const end = prompt.indexOf('one') + 'one'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toContain('text+'); + expect(result.prompt).toContain("'one+ two'"); + }); + }); + + describe('prompt function with existing attention inside args', () => { + it('should further increment already-weighted word inside arg', () => { + const prompt = "('hello+', 'world').and()"; + // Select hello+ (the word with its weight marker) + const result = adj(prompt, 'hello+', 'increment'); + expect(result.prompt).toBe("('hello++', 'world').and()"); + }); + + it('should cancel attention to neutral inside arg', () => { + const prompt = "('hello+', 'world').and()"; + const result = adj(prompt, 'hello+', 'decrement'); + expect(result.prompt).toBe("('hello', 'world').and()"); + }); + + it('should handle group attention inside arg', () => { + const prompt = "('(a b)+', 'c').and()"; + // Select everything in first arg + const start = prompt.indexOf('(a b)+'); + const end = start + '(a b)+'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('(a b)++', 'c').and()"); + }); + }); + + describe('three-arg prompt functions', () => { + it('should adjust a word in one arg of a three-arg blend', () => { + const prompt = "('a', 'b', 'c').blend(0.5, 0.3, 0.2)"; + const result = adj(prompt, 'b', 'increment'); + expect(result.prompt).toBe("('a', 'b+', 'c').blend(0.5, 0.3, 0.2)"); + }); + + it('should adjust across two separators in a three-arg blend', () => { + const prompt = "('aa bb', 'cc dd', 'ee ff').blend(0.5, 0.3, 0.2)"; + // Select from bb through ee + const start = prompt.indexOf('bb'); + const end = prompt.indexOf('ee') + 'ee'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('aa bb+', '(cc dd)+', 'ee+ ff').blend(0.5, 0.3, 0.2)"); + }); + }); + + describe('unquoted prompt functions', () => { + it('should increment a word in unquoted .and()', () => { + const prompt = '(one, two).and()'; + const result = adj(prompt, 'one', 'increment'); + expect(result.prompt).toBe('(one+, two).and()'); + }); + + it('should decrement a word in unquoted .and()', () => { + const prompt = '(one, two).and()'; + const result = adj(prompt, 'one', 'decrement'); + expect(result.prompt).toBe('(one-, two).and()'); + }); + + it('should increment a word in unquoted multi-word arg', () => { + const prompt = '(hello world, foo bar).and()'; + const result = adj(prompt, 'hello', 'increment'); + expect(result.prompt).toBe('(hello+ world, foo bar).and()'); + }); + + it('should increment all args when whole unquoted function is selected', () => { + const prompt = '(one, two).and()'; + const result = adjustPromptAttention(prompt, 0, prompt.length, 'increment'); + expect(result.prompt).toBe('(one+, two+).and()'); + }); + + it('should preserve unquoted prompt function when adjusting text outside', () => { + const prompt = 'prefix (a, b).and() suffix'; + const result = adj(prompt, 'prefix', 'increment'); + expect(result.prompt).toContain('(a, b).and()'); + expect(result.prompt).toContain('prefix+'); + }); + + it('should handle unquoted .blend() with params', () => { + const prompt = '(one two, three four).blend(0.7, 0.3)'; + const result = adj(prompt, 'one', 'increment'); + expect(result.prompt).toBe('(one+ two, three four).blend(0.7, 0.3)'); + }); + + it('should adjust across separator in unquoted prompt function', () => { + const prompt = '(one two, three four).and()'; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe('(one two+, three+ four).and()'); + }); + }); + + describe('curly-quoted prompt functions', () => { + it('should increment a word inside curly double-quoted arg', () => { + const prompt = '(\u201chello world\u201d, \u201cother\u201d).and()'; + const result = adj(prompt, 'hello', 'increment'); + expect(result.prompt).toBe('(\u201chello+ world\u201d, \u201cother\u201d).and()'); + }); + + it('should decrement a word inside curly double-quoted arg', () => { + const prompt = '(\u201chello world\u201d, \u201cother\u201d).and()'; + const result = adj(prompt, 'hello', 'decrement'); + expect(result.prompt).toBe('(\u201chello- world\u201d, \u201cother\u201d).and()'); + }); + + it('should increment a word inside curly single-quoted arg', () => { + const prompt = '(\u2018hello world\u2019, \u2018other\u2019).and()'; + const result = adj(prompt, 'hello', 'increment'); + expect(result.prompt).toBe('(\u2018hello+ world\u2019, \u2018other\u2019).and()'); + }); + + it('should increment all args when whole curly-quoted function is selected', () => { + const prompt = '(\u201cone\u201d, \u201ctwo\u201d).and()'; + const result = adjustPromptAttention(prompt, 0, prompt.length, 'increment'); + expect(result.prompt).toBe('(\u201cone+\u201d, \u201ctwo+\u201d).and()'); + }); + + it('should adjust across separator in curly double-quoted prompt function', () => { + const prompt = '(\u201cone two\u201d, \u201cthree four\u201d).and()'; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe('(\u201cone two+\u201d, \u201cthree+ four\u201d).and()'); + }); + + it('should preserve curly-quoted function when adjusting text outside', () => { + const prompt = 'prefix (\u201ca\u201d, \u201cb\u201d).and() suffix'; + const result = adj(prompt, 'prefix', 'increment'); + expect(result.prompt).toContain('(\u201ca\u201d, \u201cb\u201d).and()'); + expect(result.prompt).toContain('prefix+'); + }); + + it('should handle curly-quoted .blend() with params', () => { + const prompt = '(\u201cone two\u201d, \u201cthree four\u201d).blend(0.7, 0.3)'; + const result = adj(prompt, 'one', 'increment'); + expect(result.prompt).toBe('(\u201cone+ two\u201d, \u201cthree four\u201d).blend(0.7, 0.3)'); + }); + }); + + describe('newline before .method()', () => { + it('should increment a word in quoted prompt function with newline before .method()', () => { + const prompt = "('hello world', 'other')\n.and()"; + const result = adj(prompt, 'hello', 'increment'); + // Newline is normalized away in output + expect(result.prompt).toBe("('hello+ world', 'other').and()"); + }); + + it('should increment a word in curly-quoted prompt function with newline before .method()', () => { + const prompt = '(\u201chello world\u201d, \u201cother\u201d)\n.and()'; + const result = adj(prompt, 'hello', 'increment'); + expect(result.prompt).toBe('(\u201chello+ world\u201d, \u201cother\u201d).and()'); + }); + + it('should increment a word in unquoted prompt function with newline before .method()', () => { + const prompt = '(hello, other)\n.and()'; + const result = adj(prompt, 'hello', 'increment'); + expect(result.prompt).toBe('(hello+, other).and()'); + }); + }); + + describe('paragraph separators between args', () => { + it('should preserve newlines between quoted args when adjusting', () => { + const prompt = "('chunk 1\n\nline',\n 'chunk 2').and()"; + const result = adj(prompt, 'chunk', 'increment'); + expect(result.prompt).toBe("('chunk+ 1\n\nline',\n 'chunk 2').and()"); + }); + }); + }); + + // Selection Preservation with Prompt Functions + + describe('selection preservation with prompt functions', () => { + it('should track selection for single word inside prompt function arg', () => { + const prompt = "('hello world', 'other').and()"; + const result = adj(prompt, 'hello', 'increment'); + expect(result.prompt).toBe("('hello+ world', 'other').and()"); + expect(result.prompt.slice(result.selectionStart, result.selectionEnd)).toBe('hello+'); + }); + + it('should track selection spanning across prompt function separator', () => { + const prompt = "('one two', 'three four').and()"; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment'); + expect(result.prompt).toBe("('one two+', 'three+ four').and()"); + // Selection should span from 'two+' through 'three+' (including structural chars between) + const sel = result.prompt.slice(result.selectionStart, result.selectionEnd); + expect(sel).toContain('two+'); + expect(sel).toContain('three+'); + }); + }); + + // Edge Cases + + describe('edge cases', () => { + it('should return prompt unchanged when no selection overlap', () => { + const prompt = 'hello world'; + const result = adjustPromptAttention(prompt, 5, 5, 'increment'); + // Cursor at the boundary between hello and space — should still find a terminal + expect(result.prompt).toBeDefined(); + }); + + it('should handle empty prompt', () => { + const result = adjustPromptAttention('', 0, 0, 'increment'); + expect(result.prompt).toBe(''); + }); + + it('should not modify prompt function structure when cursor is on structural char', () => { + const prompt = "('a', 'b').and()"; + // Cursor on the dot between ) and and + const dotPos = prompt.indexOf('.and'); + const result = adjustPromptAttention(prompt, dotPos, dotPos, 'increment'); + // Should either not change or only affect content, not break the structure + expect(result.prompt).toContain('.and()'); + }); + }); + + // Numeric Weight Preference + + describe('prefersNumericWeights', () => { + describe('single word (no existing attention)', () => { + it.each([ + ['hello world', 'hello', 'increment', '(hello)1.1 world'], + ['hello world', 'hello', 'decrement', '(hello)0.9 world'], + ['hello world', 'world', 'increment', 'hello (world)1.1'], + ['hello world', 'world', 'decrement', 'hello (world)0.9'], + ] as const)('%s [%s] %s → %s', (prompt, selected, direction, expected) => { + expect(adjNumeric(prompt, selected, direction).prompt).toBe(expected); + }); + }); + + describe('successive numeric adjustments', () => { + it('should use additive step on second increment', () => { + const result = adjNumeric('(hello)1.1 world', '(hello)1.1', 'increment'); + expect(result.prompt).toBe('(hello)1.2 world'); + }); + + it('should use additive step on second decrement', () => { + const result = adjNumeric('(hello)0.9 world', '(hello)0.9', 'decrement'); + expect(result.prompt).toBe('(hello)0.8 world'); + }); + + it('should return to neutral from 1.1 on decrement', () => { + const result = adjNumeric('(hello)1.1 world', '(hello)1.1', 'decrement'); + expect(result.prompt).toBe('hello world'); + }); + }); + + describe('does not convert existing +/- attention on unselected terminals', () => { + it('should preserve +/- on unselected word when adjusting another', () => { + const result = adjNumeric('hello+ world', 'world', 'increment'); + expect(result.prompt).toContain('hello+'); + expect(result.prompt).toContain('(world)1.1'); + }); + + it('should preserve - on unselected word', () => { + const result = adjNumeric('hello- world', 'world', 'decrement'); + expect(result.prompt).toContain('hello-'); + expect(result.prompt).toContain('(world)0.9'); + }); + }); + + describe('existing +/- attention on selected terminals', () => { + it('should increment existing + word with multiplicative step (respects existing style)', () => { + const result = adjNumeric('hello+ world', 'hello+', 'increment'); + // The terminal already has explicit +/- attention, so it keeps that style + expect(result.prompt).toBe('hello++ world'); + }); + + it('should decrement existing + word to neutral', () => { + const result = adjNumeric('hello+ world', 'hello+', 'decrement'); + expect(result.prompt).toBe('hello world'); + }); + }); + + describe('existing numeric attention on selected terminals', () => { + it('should increment existing numeric weight additively', () => { + const result = adjNumeric('(detail)1.3 world', '(detail)1.3', 'increment'); + expect(result.prompt).toBe('(detail)1.4 world'); + }); + + it('should decrement existing numeric weight additively', () => { + const result = adjNumeric('(detail)1.3 world', '(detail)1.3', 'decrement'); + expect(result.prompt).toBe('(detail)1.2 world'); + }); + }); + + describe('multiple words selected', () => { + it('should wrap multiple words in numeric group on increment', () => { + const result = adjNumeric('hello world', [0, 11], 'increment'); + expect(result.prompt).toBe('(hello world)1.1'); + }); + + it('should wrap multiple words in numeric group on decrement', () => { + const result = adjNumeric('hello world', [0, 11], 'decrement'); + expect(result.prompt).toBe('(hello world)0.9'); + }); + }); + + describe('inside prompt functions', () => { + it('should use numeric format inside prompt function arg', () => { + const prompt = "('hello world', 'other').and()"; + const result = adjNumeric(prompt, 'hello', 'increment'); + expect(result.prompt).toBe("('(hello)1.1 world', 'other').and()"); + }); + + it('should use numeric format across prompt function separator', () => { + const prompt = "('one two', 'three four').and()"; + const start = prompt.indexOf('two'); + const end = prompt.indexOf('three') + 'three'.length; + const result = adjustPromptAttention(prompt, start, end, 'increment', true); + expect(result.prompt).toBe("('one (two)1.1', '(three)1.1 four').and()"); + }); + }); + + describe('group splitting inside prompt function args', () => { + it('should correctly split weighted group when decrementing a single word inside it', () => { + const prompt = + '("high detail, (cinematic lighting)1.25, soft volumetric light, (sharp focus)+, professional photography", "a young woman with balanced natural proportions, medium length brown hair, neutral expression, casual modern clothing", "subtle rim light, shallow depth of field, natural skin texture, clean background").and()'; + const result = adj(prompt, 'lighting', 'decrement'); + // "lighting" gets decremented from 1.25 → 1.25/1.1 ≈ 1.1364 + // "cinematic" stays at 1.25 + // The key thing: no space should be lost/misplaced + expect(result.prompt).toContain('(cinematic)1.25'); + expect(result.prompt).toContain('lighting)'); + // Verify there's a space between the cinematic group and lighting group + const cinIdx = result.prompt.indexOf('(cinematic)1.25'); + const afterCinematic = result.prompt.substring( + cinIdx + '(cinematic)1.25'.length, + cinIdx + '(cinematic)1.25'.length + 2 + ); + expect(afterCinematic).toMatch(/^ /); // Should start with a space + }); + + it('should rejoin groups when incrementing back to the same weight', () => { + const prompt = + '("high detail, (cinematic lighting)1.25, soft volumetric light, (sharp focus)+, professional photography", "a young woman with balanced natural proportions, medium length brown hair, neutral expression, casual modern clothing", "subtle rim light, shallow depth of field, natural skin texture, clean background").and()'; + // Decrement "lighting" to split the group + const step1 = adj(prompt, 'lighting', 'decrement'); + expect(step1.prompt).toContain('(cinematic)1.25'); + // Now increment "lighting" back — should rejoin into (cinematic lighting)1.25 + const step2 = adj(step1.prompt, 'lighting', 'increment'); + expect(step2.prompt).toContain('(cinematic lighting)1.25'); + }); + }); + + describe('numeric group whitespace trimming', () => { + it('should not capture trailing whitespace inside numeric weighted groups', () => { + // (foo bar)1.3 → decrement "bar" → (foo)1.3 (bar)X, with space between + const result = adj('(foo bar)1.3', 'bar', 'decrement'); + expect(result.prompt).toContain('(foo)1.3'); + // Space should be outside the group, not inside + expect(result.prompt).not.toContain('(foo )'); + expect(result.prompt).toMatch(/\(foo\)1\.3 /); + }); + + it('should not capture leading whitespace inside numeric weighted groups', () => { + // (foo bar)1.3 → decrement "foo" → (foo)X (bar)1.3, with space between + const result = adj('(foo bar)1.3', 'foo', 'decrement'); + expect(result.prompt).toContain('(bar)1.3'); + // Space should be outside the group, not inside + expect(result.prompt).not.toContain('( bar)'); + expect(result.prompt).toMatch(/ \(bar\)1\.3/); + }); + }); + + describe('numeric group conjoining', () => { + it('should merge adjacent same-weight numeric groups back together', () => { + // Two separate groups with same weight should conjoin into one + const result = adj('(foo)1.25 (bar)1.25', [0, 19], 'increment'); + // Both words get the same increment, so they should stay in one group + expect(result.prompt).not.toContain(') ('); + }); + + it('should merge adjacent same-weight groups when incrementing to match', () => { + // Start with (foo bar)1.3, decrement "bar", then increment it back + const step1 = adj('(foo bar)1.3', 'bar', 'decrement'); + // Now increment "bar" back — it should rejoin into a single group + const step2 = adj(step1.prompt, 'bar', 'increment'); + expect(step2.prompt).toBe('(foo bar)1.3'); + }); + + it('should merge inside prompt function args', () => { + const prompt = '("(cinematic)1.25 (lighting)1.25", "other").and()'; + const start = prompt.indexOf('cinematic'); + const end = prompt.indexOf('lighting') + 'lighting'.length; + const result = adj(prompt, [start, end], 'increment'); + // Both get incremented to same weight, should be one group + expect(result.prompt).not.toMatch(/\)\d[.\d]* \(/); + }); + }); + + describe('without prefersNumericWeights (default behavior unchanged)', () => { + it('should still use +/- syntax by default', () => { + expect(adj('hello world', 'hello', 'increment').prompt).toBe('hello+ world'); + expect(adj('hello world', 'hello', 'decrement').prompt).toBe('hello- world'); + }); + + it('should still use +/- for multiple words by default', () => { + expect(adj('hello world', [0, 11], 'increment').prompt).toBe('(hello world)+'); + }); + }); + }); }); diff --git a/invokeai/frontend/web/src/common/util/promptAttention.ts b/invokeai/frontend/web/src/common/util/promptAttention.ts index 65f25b1606..baaafdb9d6 100644 --- a/invokeai/frontend/web/src/common/util/promptAttention.ts +++ b/invokeai/frontend/web/src/common/util/promptAttention.ts @@ -1,152 +1,56 @@ import { logger } from 'app/logging/logger'; import { serializeError } from 'serialize-error'; -import { type ASTNode, type Attention, parseTokens, serialize, tokenize } from './promptAST'; +import { + type ASTNode, + type Attention, + parseTokens, + type PromptFunctionArg, + serializeWithSelection, + tokenize, +} from './promptAST'; -const log = logger('events'); +const log = logger('generation'); type AttentionDirection = 'increment' | 'decrement'; type AdjustmentResult = { prompt: string; selectionStart: number; selectionEnd: number }; const ATTENTION_STEP = 1.1; +const NUMERIC_ATTENTION_STEP = 0.1; + +/** Tolerance for floating-point weight comparisons. */ +const WEIGHT_TOLERANCE = 0.001; + +/** Tolerance for checking if a weight is a power of ATTENTION_STEP. */ +const STEP_COUNT_TOLERANCE = 0.005; + +// #region Weight Helpers /** - * Adjusts the attention of the prompt at the current cursor/selection position. + * Check if a weight is approximately ATTENTION_STEP^n for some integer n. + * Returns n if so, or null if the weight is not a power of ATTENTION_STEP. */ -export function adjustPromptAttention( - prompt: string, - selectionStart: number, - selectionEnd: number, - direction: AttentionDirection -): AdjustmentResult { - try { - const tokens = tokenize(prompt); - const ast = parseTokens(tokens); - const terminals = flattenAST(ast); - - let selectedTerminals = terminals.filter((t) => { - const isSelected = - (t.range.start < selectionEnd && t.range.end > selectionStart) || - (selectionStart === selectionEnd && t.range.start <= selectionStart && t.range.end >= selectionStart); - - if (!isSelected) { - return false; - } - - if (t.parentRange) { - const parentContainsSelection = t.parentRange.start <= selectionStart && t.parentRange.end >= selectionEnd; - const selectionCoversParent = selectionStart <= t.parentRange.start && selectionEnd >= t.parentRange.end; - - if (!parentContainsSelection && !selectionCoversParent) { - // Partial overlap. - if (t.hasExplicitAttention) { - return false; // Don't modify explicit weight in partial group - } - } - } - return true; - }); - - for (const t of selectedTerminals) { - t.isSelected = true; - } - - if (selectedTerminals.length === 0) { - const selectedGroup = findSelectedGroup(ast, selectionStart, selectionEnd); - if (selectedGroup) { - selectedTerminals = terminals.filter( - (t) => t.range.start >= selectedGroup.range.start && t.range.end <= selectedGroup.range.end - ); - for (const t of selectedTerminals) { - t.isSelected = true; - } - } - } - - if (selectedTerminals.length === 0) { - return { prompt, selectionStart, selectionEnd }; - } - - for (const terminal of selectedTerminals) { - if (direction === 'increment') { - terminal.weight *= ATTENTION_STEP; - } else { - terminal.weight /= ATTENTION_STEP; - } - } - - const newAST = groupTerminals(terminals); - const newPrompt = serialize(newAST); - const newSelection = calculateSelectionRange(newAST); - - return { - prompt: newPrompt, - selectionStart: newSelection.start, - selectionEnd: newSelection.end, - }; - } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - log.error({ error: serializeError(e) as any }, 'Failed to adjust prompt attention'); - return { prompt, selectionStart, selectionEnd }; +function getAttentionStepCount(weight: number): number | null { + if (weight <= 0) { + return null; } -} - -type Terminal = { - text: string; - type: ASTNode['type']; - weight: number; - range: { start: number; end: number }; - hasExplicitAttention: boolean; - parentRange?: { start: number; end: number }; - isSelected: boolean; -}; - -function flattenAST(ast: ASTNode[], currentWeight = 1.0, parentRange?: { start: number; end: number }): Terminal[] { - let terminals: Terminal[] = []; - - for (const node of ast) { - let nodeWeight = currentWeight; - if ('attention' in node && node.attention) { - nodeWeight *= parseAttention(node.attention); - } - - if (node.type === 'group') { - terminals.push(...flattenAST(node.children, nodeWeight, node.range)); - } else { - terminals.push({ - text: node.type === 'word' ? node.text : node.value, - type: node.type, - weight: nodeWeight, - range: node.range, - hasExplicitAttention: 'attention' in node && !!node.attention, - parentRange: parentRange, - isSelected: false, - }); - } + if (Math.abs(weight - 1.0) < WEIGHT_TOLERANCE) { + return 0; } - return terminals; -} - -function findSelectedGroup(nodes: ASTNode[], start: number, end: number): ASTNode | null { - for (const node of nodes) { - if (node.type === 'group') { - const foundInChildren = findSelectedGroup(node.children, start, end); - if (foundInChildren) { - return foundInChildren; - } - - if (rangesOverlap(node.range, { start, end })) { - return node; - } - } + const n = Math.round(Math.log(weight) / Math.log(ATTENTION_STEP)); + if (n === 0) { + return null; + } + const expected = Math.pow(ATTENTION_STEP, n); + if (Math.abs(expected - weight) < STEP_COUNT_TOLERANCE) { + return n; } return null; } -function rangesOverlap(a: { start: number; end: number }, b: { start: number; end: number }) { - return a.start < b.end && a.end > b.start; -} - +/** + * Convert an Attention value ('+', '--', 1.2, etc.) into a numeric multiplier. + */ function parseAttention(attention: Attention): number { if (typeof attention === 'number') { return attention; @@ -161,83 +65,435 @@ function parseAttention(attention: Attention): number { return isNaN(num) ? 1.0 : num; } -function calculateSelectionRange(nodes: ASTNode[]): { start: number; end: number } { - let selectionStart = Infinity; - let selectionEnd = -1; - let currentPos = 0; +/** + * Combine an existing attention value with an additional '+' or '-' level. + * Handles cancellation: e.g. '++' + '-' → '+', '+' + '-' → undefined (neutral). + */ +function addAttention(current: Attention | undefined, added: '+' | '-'): Attention | undefined { + if (!current) { + return added; + } + if (typeof current === 'number') { + if (added === '+') { + return Number((current * ATTENTION_STEP).toFixed(4)); + } + return Number((current / ATTENTION_STEP).toFixed(4)); + } + // Check if the added direction cancels the current one + const isCancel = (current.startsWith('+') && added === '-') || (current.startsWith('-') && added === '+'); + if (isCancel) { + const res = current.substring(1); + return res === '' ? undefined : res; + } + return `${current}${added}`; +} - function traverse(nodes: ASTNode[]) { - for (const node of nodes) { - if (node.isSelection) { - const len = serialize([node]).length; - selectionStart = Math.min(selectionStart, currentPos); - selectionEnd = Math.max(selectionEnd, currentPos + len); - currentPos += len; - } else { - if (node.type === 'group') { - // Group is not fully selected, but children might be. - // Group structure: "(" + children + ")" + attention - currentPos += 1; // '(' - traverse(node.children); - currentPos += 1; // ')' - if (node.attention) { - currentPos += String(node.attention).length; +// #region Terminal Type + +type Terminal = { + text: string; + type: ASTNode['type']; + weight: number; + range: { start: number; end: number }; + hasExplicitAttention: boolean; + hasNumericAttention: boolean; + parentRange?: { start: number; end: number }; + isSelected: boolean; +}; + +// #region Main Entry Point + +/** + * Adjusts the attention of the prompt at the current cursor/selection position. + * Supports regular prompts and prompt functions (.and(), .or(), .blend()). + * + * When a selection spans across a prompt function's argument separator, each + * affected argument is adjusted independently and simultaneously. + */ +export function adjustPromptAttention( + prompt: string, + selectionStart: number, + selectionEnd: number, + direction: AttentionDirection, + prefersNumericWeights = false +): AdjustmentResult { + try { + const tokens = tokenize(prompt); + const ast = parseTokens(tokens); + + const regions = extractRegions(ast); + const processedNodes: ASTNode[] = []; + let anyModified = false; + + for (const region of regions) { + if (region.type === 'normal') { + const clipped = clipSelection(selectionStart, selectionEnd, region.range); + if (clipped) { + const result = adjustRegionNodes(region.nodes, clipped.start, clipped.end, direction, prefersNumericWeights); + if (result.modified) { + anyModified = true; } + processedNodes.push(...result.nodes); } else { - // Leaf node not selected. - const len = serialize([node]).length; - currentPos += len; + processedNodes.push(...region.nodes); + } + } else { + // prompt_function region + const pfNode = region.node; + const clipped = clipSelection(selectionStart, selectionEnd, pfNode.range); + if (clipped) { + const result = adjustPromptFunctionNode(pfNode, clipped.start, clipped.end, direction, prefersNumericWeights); + if (result.modified) { + anyModified = true; + } + processedNodes.push(result.node); + } else { + processedNodes.push(pfNode); } } } - } - traverse(nodes); + if (!anyModified) { + return { prompt, selectionStart, selectionEnd }; + } - if (selectionStart === Infinity) { - return { start: 0, end: serialize(nodes).length }; + return serializeWithSelection(processedNodes); + } catch (e) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + log.error({ error: serializeError(e) as any }, 'Failed to adjust prompt attention'); + return { prompt, selectionStart, selectionEnd }; } - return { start: selectionStart, end: selectionEnd }; } +// #region Region Extraction + +type Region = + | { type: 'normal'; nodes: ASTNode[]; range: { start: number; end: number } } + | { type: 'prompt_function'; node: ASTNode & { type: 'prompt_function' } }; + +/** + * Split the top-level AST into contiguous "normal" regions and prompt function regions. + * This allows us to process prompt function arguments independently. + */ +function extractRegions(ast: ASTNode[]): Region[] { + const regions: Region[] = []; + let currentNormal: ASTNode[] = []; + + const flushNormal = () => { + if (currentNormal.length > 0) { + const first = currentNormal[0]!; + const last = currentNormal[currentNormal.length - 1]!; + regions.push({ + type: 'normal', + nodes: currentNormal, + range: { start: first.range.start, end: last.range.end }, + }); + currentNormal = []; + } + }; + + for (const node of ast) { + if (node.type === 'prompt_function') { + flushNormal(); + regions.push({ type: 'prompt_function', node }); + } else { + currentNormal.push(node); + } + } + flushNormal(); + + return regions; +} + +/** + * Clip a selection range to a target range. Returns null if there is no overlap. + * For cursor positions (start === end), checks containment including boundaries. + */ +function clipSelection( + selStart: number, + selEnd: number, + range: { start: number; end: number } +): { start: number; end: number } | null { + if (selStart === selEnd) { + // Cursor position: check if within range (inclusive of boundaries) + if (selStart >= range.start && selStart <= range.end) { + return { start: selStart, end: selEnd }; + } + return null; + } + const clippedStart = Math.max(selStart, range.start); + const clippedEnd = Math.min(selEnd, range.end); + if (clippedStart >= clippedEnd) { + return null; + } + return { start: clippedStart, end: clippedEnd }; +} + +// #region Prompt Function Handling + +/** + * Adjust attention within a prompt function node by processing each argument + * whose content range overlaps the selection independently. + * Returns the (possibly updated) node and whether any modification was made. + */ +function adjustPromptFunctionNode( + pf: ASTNode & { type: 'prompt_function' }, + selStart: number, + selEnd: number, + direction: AttentionDirection, + prefersNumericWeights = false +): { node: ASTNode & { type: 'prompt_function' }; modified: boolean } { + let modified = false; + const newArgs: PromptFunctionArg[] = pf.promptArgs.map((arg) => { + const clipped = clipSelection(selStart, selEnd, arg.contentRange); + if (clipped) { + const result = adjustRegionNodes(arg.nodes, clipped.start, clipped.end, direction, prefersNumericWeights); + if (result.modified) { + modified = true; + return { ...arg, nodes: result.nodes }; + } + } + return arg; + }); + + if (!modified) { + return { node: pf, modified: false }; + } + + return { node: { ...pf, promptArgs: newArgs }, modified: true }; +} + +// #region Core Attention Adjustment + +/** + * Adjust attention for a set of AST nodes (a "region") given a selection range. + * This is the core flatten → select → adjust → regroup pipeline. + * Returns the adjusted nodes and whether any modification was made. + */ +function adjustRegionNodes( + nodes: ASTNode[], + selStart: number, + selEnd: number, + direction: AttentionDirection, + prefersNumericWeights = false +): { nodes: ASTNode[]; modified: boolean } { + const terminals = flattenAST(nodes); + + let selectedTerminals = selectTerminals(terminals, selStart, selEnd); + + // Fallback: if no terminals were selected, try to find an overlapping group + if (selectedTerminals.length === 0) { + const group = findSelectedGroup(nodes, selStart, selEnd); + if (group) { + selectedTerminals = terminals.filter((t) => t.range.start >= group.range.start && t.range.end <= group.range.end); + } + } + + if (selectedTerminals.length === 0) { + return { nodes, modified: false }; + } + + for (const t of selectedTerminals) { + t.isSelected = true; + // When the user prefers numeric weights and the terminal doesn't already + // have explicit attention, mark it as numeric so adjustWeights uses + // additive steps and groupTerminals emits numeric syntax. + if (prefersNumericWeights && !t.hasExplicitAttention) { + t.hasNumericAttention = true; + } + } + + adjustWeights(selectedTerminals, direction); + + return { nodes: groupTerminals(terminals), modified: true }; +} + +// #region Flatten AST to Terminals + +/** + * Flatten an AST into a flat list of terminals, computing the effective weight + * of each terminal by accumulating attention from ancestor groups. + */ +function flattenAST( + ast: ASTNode[], + currentWeight = 1.0, + parentRange?: { start: number; end: number }, + numericAttention = false +): Terminal[] { + const terminals: Terminal[] = []; + + for (const node of ast) { + let nodeWeight = currentWeight; + let nodeNumericAttention = numericAttention; + if ((node.type === 'word' || node.type === 'group') && node.attention) { + nodeWeight *= parseAttention(node.attention); + nodeNumericAttention = typeof node.attention === 'number'; + } + + if (node.type === 'group') { + terminals.push(...flattenAST(node.children, nodeWeight, node.range, nodeNumericAttention)); + } else if (node.type === 'prompt_function') { + // Prompt functions should not appear inside regions being flattened; + // they are handled at the region level. If one somehow appears, skip it. + continue; + } else { + terminals.push({ + text: node.type === 'word' ? node.text : node.value, + type: node.type, + weight: nodeWeight, + range: node.range, + hasExplicitAttention: node.type === 'word' && !!node.attention, + hasNumericAttention: nodeNumericAttention, + parentRange, + isSelected: false, + }); + } + } + return terminals; +} + +// #region Terminal Selection + +/** + * Find terminals that overlap the selection range and should be affected + * by the attention adjustment. Handles partial group overlap carefully: + * terminals with explicit attention inside partially-overlapping groups + * are excluded to avoid corrupting explicit weights. + * + * When the cursor is at a boundary between two tokens (e.g. "word|,"), + * both tokens technically overlap the cursor position. In this case we + * prefer word/embedding terminals over punctuation/whitespace so that + * adjusting attention at a word boundary doesn't accidentally include + * adjacent punctuation. + */ +function selectTerminals(terminals: Terminal[], selStart: number, selEnd: number): Terminal[] { + const result = terminals.filter((t) => { + const isOverlapping = + (t.range.start < selEnd && t.range.end > selStart) || + (selStart === selEnd && t.range.start <= selStart && t.range.end >= selStart); + + if (!isOverlapping) { + return false; + } + + if (t.parentRange) { + const parentContainsSelection = t.parentRange.start <= selStart && t.parentRange.end >= selEnd; + const selectionCoversParent = selStart <= t.parentRange.start && selEnd >= t.parentRange.end; + + if (!parentContainsSelection && !selectionCoversParent) { + // Partial overlap between selection and parent group + if (t.hasExplicitAttention) { + return false; // Don't modify explicit weight in partially-overlapping group + } + } + } + return true; + }); + + // When the cursor is at a token boundary (no selection range), multiple tokens + // can match. Prefer word/embedding terminals over punctuation/whitespace. + if (selStart === selEnd && result.length > 1) { + const contentTerminals = result.filter((t) => t.type === 'word' || t.type === 'embedding'); + if (contentTerminals.length > 0) { + return contentTerminals; + } + } + + return result; +} + +// #region Weight Adjustment + +/** + * Apply weight changes to the selected terminals based on direction. + * Numeric weights use additive steps; +/- syntax uses multiplicative steps. + * All results are rounded to 4 decimal places to prevent floating-point drift. + */ +function adjustWeights(terminals: Terminal[], direction: AttentionDirection): void { + for (const terminal of terminals) { + if (terminal.hasNumericAttention) { + // Additive step for explicit numeric weights (e.g. 1.1 → 1.2) + if (direction === 'increment') { + terminal.weight = Number((terminal.weight + NUMERIC_ATTENTION_STEP).toFixed(4)); + } else { + terminal.weight = Number((terminal.weight - NUMERIC_ATTENTION_STEP).toFixed(4)); + } + } else { + // Multiplicative step for +/- syntax weights, rounded to prevent drift + if (direction === 'increment') { + terminal.weight = Number((terminal.weight * ATTENTION_STEP).toFixed(4)); + } else { + terminal.weight = Number((terminal.weight / ATTENTION_STEP).toFixed(4)); + } + } + } +} + +// #region Find Selected Group (fallback) + +/** + * When no terminals directly overlap the selection (e.g. cursor is on a group + * boundary character), find the innermost group that overlaps the selection. + */ +function findSelectedGroup(nodes: ASTNode[], start: number, end: number): ASTNode | null { + for (const node of nodes) { + if (node.type === 'group') { + const foundInChildren = findSelectedGroup(node.children, start, end); + if (foundInChildren) { + return foundInChildren; + } + if (node.range.start < end && node.range.end > start) { + return node; + } + } + } + return null; +} + +// #region Regroup Terminals into AST + +/** + * Reconstruct an AST from a flat list of terminals with adjusted weights. + * Groups consecutive terminals with compatible weights using +/- or numeric syntax. + * + * Note: Reconstructed group nodes use `range: { start: 0, end: 0 }` as a sentinel + * value since the original source positions are no longer meaningful after regrouping. + * These nodes are only used for serialization output, never for source-position lookups. + */ function groupTerminals(terminals: Terminal[]): ASTNode[] { if (terminals.length === 0) { return []; } + /** Sentinel range for reconstructed nodes whose original positions are not applicable. */ + const NO_RANGE = { start: 0, end: 0 }; + const nodes: ASTNode[] = []; let i = 0; + while (i < terminals.length) { const t = terminals[i]!; const weight = t.weight; + const stepCount = getAttentionStepCount(weight); - const findRunEnd = (predicate: (w: number) => boolean) => { - let j = i; - while (j < terminals.length) { - const next = terminals[j]!; - if (predicate(next.weight)) { - j++; - } else if (next.type === 'whitespace') { - let k = j + 1; - while (k < terminals.length && terminals[k]!.type === 'whitespace') { - k++; - } - if (k < terminals.length && predicate(terminals[k]!.weight)) { - j = k; - } else { - break; - } - } else { - break; + // ── +/- attention (weight is a non-zero power of ATTENTION_STEP) ── + // Skip this branch if the terminal prefers numeric format to avoid an + // infinite loop (predicate would reject it, findRunEnd returns i, i never advances). + if (stepCount !== null && stepCount !== 0 && !t.hasNumericAttention) { + const isPositive = stepCount > 0; + const sign: '+' | '-' = isPositive ? '+' : '-'; + const predicate = (t: Terminal): boolean => { + if (t.hasNumericAttention) { + return false; // Numeric-preference terminals should not join +/- runs } - } - return j; - }; + const sc = getAttentionStepCount(t.weight); + return sc !== null && (isPositive ? sc > 0 : sc < 0); + }; + const factor = isPositive ? ATTENTION_STEP : 1 / ATTENTION_STEP; - // Check for + (>= 1.1) - if (weight >= ATTENTION_STEP - 0.001) { - const j = findRunEnd((w) => w >= ATTENTION_STEP - 0.001); + const j = findRunEnd(terminals, i, predicate); + // Trim whitespace from the content run boundaries let runStart = i; let runEnd = j; while (runStart < runEnd && terminals[runStart]!.type === 'whitespace') { @@ -247,28 +503,31 @@ function groupTerminals(terminals: Terminal[]): ASTNode[] { runEnd--; } + // Emit leading whitespace as standalone nodes for (let k = i; k < runStart; k++) { nodes.push(createNodeFromTerminal(terminals[k]!)); } if (runStart < runEnd) { - const slice = terminals.slice(runStart, runEnd).map((t) => ({ ...t, weight: t.weight / ATTENTION_STEP })); + // Factor out one level of attention and recurse + const slice = terminals.slice(runStart, runEnd).map((t) => ({ ...t, weight: t.weight / factor })); const children = groupTerminals(slice); const isSelection = slice.every((t) => t.isSelected); if (children.length === 1) { const child = children[0]!; if (child.type === 'word' || child.type === 'group') { - const newAttention = addAttention(child.attention, '+'); - nodes.push({ ...child, attention: newAttention }); + const newAttention = addAttention(child.attention, sign); + nodes.push({ ...child, attention: newAttention, isSelection: isSelection || undefined }); } else { - nodes.push({ type: 'group', children, attention: '+', range: { start: 0, end: 0 }, isSelection }); + nodes.push({ type: 'group', children, attention: sign, range: NO_RANGE, isSelection }); } } else { - nodes.push({ type: 'group', children, attention: '+', range: { start: 0, end: 0 }, isSelection }); + nodes.push({ type: 'group', children, attention: sign, range: NO_RANGE, isSelection }); } } + // Emit trailing whitespace as standalone nodes for (let k = runEnd; k < j; k++) { nodes.push(createNodeFromTerminal(terminals[k]!)); } @@ -277,126 +536,103 @@ function groupTerminals(terminals: Terminal[]): ASTNode[] { continue; } - // Check for - (<= 0.909) - if (weight <= 1 / ATTENTION_STEP + 0.001) { - const j = findRunEnd((w) => w <= 1 / ATTENTION_STEP + 0.001); - - let runStart = i; - let runEnd = j; - while (runStart < runEnd && terminals[runStart]!.type === 'whitespace') { - runStart++; - } - while (runEnd > runStart && terminals[runEnd - 1]!.type === 'whitespace') { - runEnd--; - } - - for (let k = i; k < runStart; k++) { - nodes.push(createNodeFromTerminal(terminals[k]!)); - } - - if (runStart < runEnd) { - const slice = terminals.slice(runStart, runEnd).map((t) => ({ ...t, weight: t.weight * ATTENTION_STEP })); - const children = groupTerminals(slice); - const isSelection = slice.every((t) => t.isSelected); - - if (children.length === 1) { - const child = children[0]!; - if (child.type === 'word' || child.type === 'group') { - const newAttention = addAttention(child.attention, '-'); - nodes.push({ ...child, attention: newAttention }); - } else { - nodes.push({ type: 'group', children, attention: '-', range: { start: 0, end: 0 }, isSelection }); - } - } else { - nodes.push({ type: 'group', children, attention: '-', range: { start: 0, end: 0 }, isSelection }); - } - } - - for (let k = runEnd; k < j; k++) { - nodes.push(createNodeFromTerminal(terminals[k]!)); - } - - i = j; - continue; - } - - // Residual or 1.0 - if (Math.abs(weight - 1.0) < 0.001) { + // ── Neutral weight (≈ 1.0) ── + if (Math.abs(weight - 1.0) < WEIGHT_TOLERANCE) { nodes.push(createNodeFromTerminal(t)); i++; - } else { - let j = i; - while (j < terminals.length && Math.abs(terminals[j]!.weight - weight) < 0.001) { - j++; + continue; + } + + // ── Numeric weight (not a power of ATTENTION_STEP) ── + { + const j = findRunEnd(terminals, i, (t) => Math.abs(t.weight - weight) < WEIGHT_TOLERANCE); + + // Trim whitespace from the content run boundaries (same as +/- branch) + let runStart = i; + let runEnd = j; + while (runStart < runEnd && terminals[runStart]!.type === 'whitespace') { + runStart++; + } + while (runEnd > runStart && terminals[runEnd - 1]!.type === 'whitespace') { + runEnd--; } - const groupTerminalsSlice = terminals.slice(i, j).map((t) => ({ ...t, weight: 1.0 })); - const children = groupTerminals(groupTerminalsSlice); - const isSelection = groupTerminalsSlice.every((t) => t.isSelected); - - const weightStr = Number(weight.toFixed(4)); - - if (children.length === 1) { - const child = children[0]!; - if (child.type === 'word' || child.type === 'group') { - nodes.push({ ...child, attention: weightStr }); - } else { - nodes.push({ type: 'group', children, attention: weightStr, range: { start: 0, end: 0 }, isSelection }); - } - } else { - nodes.push({ type: 'group', children, attention: weightStr, range: { start: 0, end: 0 }, isSelection }); + // Emit leading whitespace as standalone nodes + for (let k = i; k < runStart; k++) { + nodes.push(createNodeFromTerminal(terminals[k]!)); } + + if (runStart < runEnd) { + const groupSlice = terminals.slice(runStart, runEnd).map((t) => ({ ...t, weight: 1.0 })); + const children = groupTerminals(groupSlice); + const isSelection = groupSlice.every((t) => t.isSelected); + const weightNum = Number(weight.toFixed(4)); + + nodes.push({ type: 'group', children, attention: weightNum, range: NO_RANGE, isSelection }); + } + + // Emit trailing whitespace as standalone nodes + for (let k = runEnd; k < j; k++) { + nodes.push(createNodeFromTerminal(terminals[k]!)); + } + i = j; } } return nodes; } -function createNodeFromTerminal(t: Terminal): ASTNode { - if (t.type === 'word') { - return { type: 'word', text: t.text, range: t.range, isSelection: t.isSelected }; +/** + * Find the end of a "run" of terminals whose weights satisfy a predicate. + * Whitespace terminals are included if the next non-whitespace terminal also satisfies the predicate. + * Note: The returned index may point to a whitespace token that is NOT included in the run; + * the caller is responsible for trimming trailing whitespace from the run boundaries. + */ +function findRunEnd(terminals: Terminal[], start: number, predicate: (t: Terminal) => boolean): number { + let j = start; + while (j < terminals.length) { + const next = terminals[j]!; + if (predicate(next)) { + j++; + } else if (next.type === 'whitespace') { + // Look ahead past consecutive whitespace + let k = j + 1; + while (k < terminals.length && terminals[k]!.type === 'whitespace') { + k++; + } + if (k < terminals.length && predicate(terminals[k]!)) { + j = k; + } else { + break; + } + } else { + break; + } } - if (t.type === 'whitespace') { - return { type: 'whitespace', value: t.text, range: t.range, isSelection: t.isSelected }; - } - if (t.type === 'punct') { - return { type: 'punct', value: t.text, range: t.range, isSelection: t.isSelected }; - } - if (t.type === 'embedding') { - return { type: 'embedding', value: t.text, range: t.range, isSelection: t.isSelected }; - } - if (t.type === 'escaped_paren') { - return { type: 'escaped_paren', value: t.text as '(' | ')', range: t.range, isSelection: t.isSelected }; - } - return { type: 'word', text: t.text, range: t.range, isSelection: t.isSelected }; + return j; } -function addAttention(current: Attention | undefined, added: string): Attention | undefined { - if (!current) { - return added; +/** + * Convert a Terminal back into a leaf ASTNode. + */ +function createNodeFromTerminal(t: Terminal): ASTNode { + switch (t.type) { + case 'word': + return { type: 'word', text: t.text, range: t.range, isSelection: t.isSelected || undefined }; + case 'whitespace': + return { type: 'whitespace', value: t.text, range: t.range, isSelection: t.isSelected || undefined }; + case 'punct': + return { type: 'punct', value: t.text, range: t.range, isSelection: t.isSelected || undefined }; + case 'embedding': + return { type: 'embedding', value: t.text, range: t.range, isSelection: t.isSelected || undefined }; + case 'escaped_paren': + return { + type: 'escaped_paren', + value: t.text as '(' | ')', + range: t.range, + isSelection: t.isSelected || undefined, + }; + default: + return { type: 'word', text: t.text, range: t.range, isSelection: t.isSelected || undefined }; } - if (typeof current === 'number') { - if (added === '+') { - return current * ATTENTION_STEP; - } - if (added === '-') { - return current / ATTENTION_STEP; - } - return current; - } - if (added === '+') { - if (current.startsWith('-')) { - const res = current.substring(1); - return res === '' ? undefined : res; - } - return `${current}+`; - } - if (added === '-') { - if (current.startsWith('+')) { - const res = current.substring(1); - return res === '' ? undefined : res; - } - return `${current}-`; - } - return current; } diff --git a/invokeai/frontend/web/src/features/prompt/usePromptAttentionHotkeys.ts b/invokeai/frontend/web/src/features/prompt/usePromptAttentionHotkeys.ts index 545857b9e2..7959c06b5b 100644 --- a/invokeai/frontend/web/src/features/prompt/usePromptAttentionHotkeys.ts +++ b/invokeai/frontend/web/src/features/prompt/usePromptAttentionHotkeys.ts @@ -1,5 +1,7 @@ +import { useAppSelector } from 'app/store/storeHooks'; import { adjustPromptAttention } from 'common/util/promptAttention'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { selectSystemPrefersNumericAttentionWeights } from 'features/system/store/systemSlice'; import type { RefObject } from 'react'; import { useCallback } from 'react'; @@ -18,6 +20,7 @@ export const usePromptAttentionHotkeys = ({ onPromptChange: _onPromptChange, }: UsePromptAttentionHotkeysArgs) => { const isPromptFocused = useCallback(() => document.activeElement === textareaRef.current, [textareaRef]); + const prefersNumericWeights = useAppSelector(selectSystemPrefersNumericAttentionWeights); const handleAttentionAdjustment = useCallback( (direction: 'increment' | 'decrement') => { @@ -26,7 +29,13 @@ export const usePromptAttentionHotkeys = ({ return; } - const result = adjustPromptAttention(textarea.value, textarea.selectionStart, textarea.selectionEnd, direction); + const result = adjustPromptAttention( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd, + direction, + prefersNumericWeights + ); // Use execCommand to make the change undo-able by the browser. // This triggers the textarea's native onChange, which syncs React state. @@ -37,7 +46,7 @@ export const usePromptAttentionHotkeys = ({ // Restore the selection to cover the adjusted portion textarea.setSelectionRange(result.selectionStart, result.selectionEnd); }, - [textareaRef] + [textareaRef, prefersNumericWeights] ); useRegisteredHotkeys({ @@ -50,7 +59,7 @@ export const usePromptAttentionHotkeys = ({ } }, options: { preventDefault: true, enableOnFormTags: ['TEXTAREA'] }, - dependencies: [isPromptFocused, handleAttentionAdjustment], + dependencies: [isPromptFocused, handleAttentionAdjustment, prefersNumericWeights], }); useRegisteredHotkeys({ @@ -63,6 +72,6 @@ export const usePromptAttentionHotkeys = ({ } }, options: { preventDefault: true, enableOnFormTags: ['TEXTAREA'] }, - dependencies: [isPromptFocused, handleAttentionAdjustment], + dependencies: [isPromptFocused, handleAttentionAdjustment, prefersNumericWeights], }); }; diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index 6c7ebada8f..b95d2adb47 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -27,6 +27,7 @@ import { SettingsDeveloperLogNamespaces } from 'features/system/components/Setti import { useClearIntermediates } from 'features/system/components/SettingsModal/useClearIntermediates'; import { StickyScrollable } from 'features/system/components/StickyScrollable'; import { + selectSystemPrefersNumericAttentionWeights, selectSystemShouldAntialiasProgressImage, selectSystemShouldConfirmOnDelete, selectSystemShouldConfirmOnNewSession, @@ -36,6 +37,7 @@ import { selectSystemShouldShowInvocationProgressDetail, selectSystemShouldUseNSFWChecker, selectSystemShouldUseWatermarker, + setPrefersNumericAttentionStyle, setShouldConfirmOnDelete, setShouldEnableInformationalPopovers, setShouldEnableModelDescriptions, @@ -71,6 +73,7 @@ const SettingsModal = (props: { children: ReactElement }) => { const settingsModal = useSettingsModal(); const refreshModal = useRefreshAfterResetModal(); + const prefersNumericAttentionWeights = useAppSelector(selectSystemPrefersNumericAttentionWeights); const shouldUseCpuNoise = useAppSelector(selectShouldUseCPUNoise); const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); @@ -162,6 +165,13 @@ const SettingsModal = (props: { children: ReactElement }) => { [dispatch] ); + const handleChangePreferAttentionStyleNumeric = useCallback( + (e: ChangeEvent) => { + dispatch(setPrefersNumericAttentionStyle(e.target.checked)); + }, + [dispatch] + ); + return ( <> {cloneElement(props.children, { @@ -250,6 +260,16 @@ const SettingsModal = (props: { children: ReactElement }) => { + + + {t('settings.preferAttentionStyleNumeric')} + + + + diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index 1cc22c8dea..e4346a1794 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -26,6 +26,7 @@ const getInitialState = (): SystemState => ({ logNamespaces: [...zLogNamespace.options], shouldShowInvocationProgressDetail: false, shouldHighlightFocusedRegions: false, + prefersNumericAttentionWeights: false, }); const slice = createSlice({ @@ -69,6 +70,9 @@ const slice = createSlice({ shouldConfirmOnNewSessionToggled(state) { state.shouldConfirmOnNewSession = !state.shouldConfirmOnNewSession; }, + setPrefersNumericAttentionStyle(state, action: PayloadAction) { + state.prefersNumericAttentionWeights = action.payload; + }, setShouldShowInvocationProgressDetail(state, action: PayloadAction) { state.shouldShowInvocationProgressDetail = action.payload; }, @@ -91,6 +95,7 @@ export const { setShouldEnableModelDescriptions, shouldConfirmOnNewSessionToggled, setShouldShowInvocationProgressDetail, + setPrefersNumericAttentionStyle, setShouldHighlightFocusedRegions, } = slice.actions; @@ -136,6 +141,9 @@ export const selectSystemShouldEnableModelDescriptions = createSystemSelector( export const selectSystemShouldEnableHighlightFocusedRegions = createSystemSelector( (system) => system.shouldHighlightFocusedRegions ); +export const selectSystemPrefersNumericAttentionWeights = createSystemSelector( + (system) => system.prefersNumericAttentionWeights +); export const selectSystemShouldConfirmOnNewSession = createSystemSelector((system) => system.shouldConfirmOnNewSession); export const selectSystemShouldShowInvocationProgressDetail = createSystemSelector( (system) => system.shouldShowInvocationProgressDetail diff --git a/invokeai/frontend/web/src/features/system/store/types.ts b/invokeai/frontend/web/src/features/system/store/types.ts index 3eaf8628c6..4cf4fb784b 100644 --- a/invokeai/frontend/web/src/features/system/store/types.ts +++ b/invokeai/frontend/web/src/features/system/store/types.ts @@ -44,5 +44,6 @@ export const zSystemState = z.object({ logNamespaces: z.array(zLogNamespace), shouldShowInvocationProgressDetail: z.boolean(), shouldHighlightFocusedRegions: z.boolean(), + prefersNumericAttentionWeights: z.boolean(), }); export type SystemState = z.infer; From df225d37511304e8c2fb5ee69fd1e98b1dac3379 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 7 Mar 2026 02:24:02 +0100 Subject: [PATCH 14/24] Fix model reidentify losing path and failing on IP Adapters (#8941) The reidentify endpoint overwrote the model's relative path with an absolute path from the prober, and unconditionally accessed trigger_phrases which doesn't exist on all config types (e.g. IP Adapters), causing an AttributeError. Co-authored-by: Lincoln Stein --- invokeai/app/api/routers/model_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 234c6c9662..9d5b41e7f5 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -246,11 +246,13 @@ async def reidentify_model( raise InvalidModelException("Unable to identify model format") # Retain user-editable fields from the original config + result.config.path = config.path result.config.key = config.key result.config.name = config.name result.config.description = config.description result.config.cover_image = config.cover_image - result.config.trigger_phrases = config.trigger_phrases + if hasattr(config, "trigger_phrases") and hasattr(result.config, "trigger_phrases"): + result.config.trigger_phrases = config.trigger_phrases result.config.source = config.source result.config.source_type = config.source_type From 3d81edac61b45b878d759d94ca930b7e0c9bf18d Mon Sep 17 00:00:00 2001 From: girlyoulookthebest <36124063+girlyoulookthebest@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:43:12 -0700 Subject: [PATCH 15/24] perf(flux2): optimize cache locking in Klein encoder to fix #7513 (#8863) * perf(flux2): optimize model loading order to prevent cache eviction (fixes #7513) * Update flux2_klein_text_encoder.py * Update flux2_klein_text_encoder.py version --------- Co-authored-by: Alexander Eichhorn Co-authored-by: Lincoln Stein --- .../invocations/flux2_klein_text_encoder.py | 201 ++++++++---------- 1 file changed, 86 insertions(+), 115 deletions(-) diff --git a/invokeai/app/invocations/flux2_klein_text_encoder.py b/invokeai/app/invocations/flux2_klein_text_encoder.py index b823cec31b..6ca307ebf0 100644 --- a/invokeai/app/invocations/flux2_klein_text_encoder.py +++ b/invokeai/app/invocations/flux2_klein_text_encoder.py @@ -45,7 +45,7 @@ KLEIN_MAX_SEQ_LEN = 512 title="Prompt - Flux2 Klein", tags=["prompt", "conditioning", "flux", "klein", "qwen3"], category="conditioning", - version="1.1.0", + version="1.1.1", classification=Classification.Prototype, ) class Flux2KleinTextEncoderInvocation(BaseInvocation): @@ -73,140 +73,111 @@ class Flux2KleinTextEncoderInvocation(BaseInvocation): @torch.no_grad() def invoke(self, context: InvocationContext) -> FluxConditioningOutput: - qwen3_embeds, pooled_embeds = self._encode_prompt(context) + # Open the exitstack here to lock models for the duration of the node + with ExitStack() as exit_stack: + # Pass the locked stack down to the helper function + qwen3_embeds, pooled_embeds = self._encode_prompt(context, exit_stack) - # Use FLUXConditioningInfo for compatibility with existing Flux denoiser - # t5_embeds -> qwen3 stacked embeddings - # clip_embeds -> pooled qwen3 embedding - conditioning_data = ConditioningFieldData( - conditionings=[FLUXConditioningInfo(clip_embeds=pooled_embeds, t5_embeds=qwen3_embeds)] - ) + conditioning_data = ConditioningFieldData( + conditionings=[FLUXConditioningInfo(clip_embeds=pooled_embeds, t5_embeds=qwen3_embeds)] + ) - conditioning_name = context.conditioning.save(conditioning_data) - return FluxConditioningOutput( - conditioning=FluxConditioningField(conditioning_name=conditioning_name, mask=self.mask) - ) + # The models are still locked while we save the data + conditioning_name = context.conditioning.save(conditioning_data) + return FluxConditioningOutput( + conditioning=FluxConditioningField(conditioning_name=conditioning_name, mask=self.mask) + ) - def _encode_prompt(self, context: InvocationContext) -> Tuple[torch.Tensor, torch.Tensor]: - """Encode prompt using Qwen3 text encoder with Klein-style layer extraction. - - This matches the diffusers Flux2KleinPipeline._get_qwen3_prompt_embeds() exactly. - - Returns: - Tuple of (stacked_embeddings, pooled_embedding): - - stacked_embeddings: Hidden states from layers (9, 18, 27) stacked together. - Shape: (1, seq_len, hidden_size * 3) - - pooled_embedding: Pooled representation for global conditioning. - Shape: (1, hidden_size) - """ + def _encode_prompt(self, context: InvocationContext, exit_stack: ExitStack) -> Tuple[torch.Tensor, torch.Tensor]: prompt = self.prompt + # Reordered loading to prevent the annoying cache drop issue + # This prevents it from being evicted while we look up the tokenizer text_encoder_info = context.models.load(self.qwen3_encoder.text_encoder) + (cached_weights, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device()) + + # Now it is safe to load and lock the tokenizer tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer) + (_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device()) - with ExitStack() as exit_stack: - (cached_weights, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device()) - (_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device()) + device = text_encoder.device - # you can now define the device, as the text_encoder exists here - device = text_encoder.device + # Apply LoRA models + lora_dtype = TorchDevice.choose_bfloat16_safe_dtype(device) + exit_stack.enter_context( + LayerPatcher.apply_smart_model_patches( + model=text_encoder, + patches=self._lora_iterator(context), + prefix=FLUX_LORA_T5_PREFIX, + dtype=lora_dtype, + cached_weights=cached_weights, + ) + ) - # Apply LoRA models to the text encoder - lora_dtype = TorchDevice.choose_bfloat16_safe_dtype(device) - exit_stack.enter_context( - LayerPatcher.apply_smart_model_patches( - model=text_encoder, - patches=self._lora_iterator(context), - prefix=FLUX_LORA_T5_PREFIX, # Reuse T5 prefix for Qwen3 LoRAs - dtype=lora_dtype, - cached_weights=cached_weights, - ) + context.util.signal_progress("Running Qwen3 text encoder (Klein)") + + if not isinstance(text_encoder, PreTrainedModel): + raise TypeError( + f"Expected PreTrainedModel for text encoder, got {type(text_encoder).__name__}. " + "The Qwen3 encoder model may be corrupted or incompatible." + ) + if not isinstance(tokenizer, PreTrainedTokenizerBase): + raise TypeError( + f"Expected PreTrainedTokenizerBase for tokenizer, got {type(tokenizer).__name__}. " + "The Qwen3 tokenizer may be corrupted or incompatible." ) - context.util.signal_progress("Running Qwen3 text encoder (Klein)") + messages = [{"role": "user", "content": prompt}] - if not isinstance(text_encoder, PreTrainedModel): - raise TypeError( - f"Expected PreTrainedModel for text encoder, got {type(text_encoder).__name__}. " - "The Qwen3 encoder model may be corrupted or incompatible." - ) - if not isinstance(tokenizer, PreTrainedTokenizerBase): - raise TypeError( - f"Expected PreTrainedTokenizerBase for tokenizer, got {type(tokenizer).__name__}. " - "The Qwen3 tokenizer may be corrupted or incompatible." - ) + text: str = tokenizer.apply_chat_template( # type: ignore[assignment] + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=False, + ) - # Format messages exactly like diffusers Flux2KleinPipeline: - # - Only user message, NO system message - # - add_generation_prompt=True (adds assistant prefix) - # - enable_thinking=False - messages = [{"role": "user", "content": prompt}] + inputs = tokenizer( + text, + return_tensors="pt", + padding="max_length", + truncation=True, + max_length=self.max_seq_len, + ) - # Step 1: Apply chat template to get formatted text (tokenize=False) - text: str = tokenizer.apply_chat_template( # type: ignore[assignment] - messages, - tokenize=False, - add_generation_prompt=True, # Adds assistant prefix like diffusers - enable_thinking=False, # Disable thinking mode + input_ids = inputs["input_ids"].to(device) + attention_mask = inputs["attention_mask"].to(device) + + # Forward pass through the model + outputs = text_encoder( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=True, + use_cache=False, + ) + if not hasattr(outputs, "hidden_states") or outputs.hidden_states is None: + raise RuntimeError( + "Text encoder did not return hidden_states. " + "Ensure output_hidden_states=True is supported by this model." ) + num_hidden_layers = len(outputs.hidden_states) - # Step 2: Tokenize the formatted text - inputs = tokenizer( - text, - return_tensors="pt", - padding="max_length", - truncation=True, - max_length=self.max_seq_len, - ) + hidden_states_list = [] + for layer_idx in KLEIN_EXTRACTION_LAYERS: + if layer_idx >= num_hidden_layers: + layer_idx = num_hidden_layers - 1 + hidden_states_list.append(outputs.hidden_states[layer_idx]) - input_ids = inputs["input_ids"].to(device) - attention_mask = inputs["attention_mask"].to(device) + out = torch.stack(hidden_states_list, dim=1) + out = out.to(dtype=text_encoder.dtype, device=device) - # Move to device - input_ids = input_ids.to(device) - attention_mask = attention_mask.to(device) + batch_size, num_channels, seq_len, hidden_dim = out.shape + prompt_embeds = out.permute(0, 2, 1, 3).reshape(batch_size, seq_len, num_channels * hidden_dim) - # Forward pass through the model - matching diffusers exactly - # Explicitly move inputs to the same device as the text_encoder - outputs = text_encoder( - input_ids=input_ids, - attention_mask=attention_mask, - output_hidden_states=True, - use_cache=False, - ) - # Validate hidden_states output - if not hasattr(outputs, "hidden_states") or outputs.hidden_states is None: - raise RuntimeError( - "Text encoder did not return hidden_states. " - "Ensure output_hidden_states=True is supported by this model." - ) - num_hidden_layers = len(outputs.hidden_states) - - # Extract and stack hidden states - EXACTLY like diffusers: - # out = torch.stack([output.hidden_states[k] for k in hidden_states_layers], dim=1) - # prompt_embeds = out.permute(0, 2, 1, 3).reshape(batch_size, seq_len, num_channels * hidden_dim) - hidden_states_list = [] - for layer_idx in KLEIN_EXTRACTION_LAYERS: - if layer_idx >= num_hidden_layers: - layer_idx = num_hidden_layers - 1 - hidden_states_list.append(outputs.hidden_states[layer_idx]) - - # Stack along dim=1, then permute and reshape - exactly like diffusers - out = torch.stack(hidden_states_list, dim=1) - out = out.to(dtype=text_encoder.dtype, device=device) - - batch_size, num_channels, seq_len, hidden_dim = out.shape - prompt_embeds = out.permute(0, 2, 1, 3).reshape(batch_size, seq_len, num_channels * hidden_dim) - - # Create pooled embedding for global conditioning - # Use mean pooling over the sequence (excluding padding) - # This serves a similar role to CLIP's pooled output in standard FLUX - last_hidden_state = outputs.hidden_states[-1] # Use last layer for pooling - # Expand mask to match hidden state dimensions - expanded_mask = attention_mask.unsqueeze(-1).expand_as(last_hidden_state).float() - sum_embeds = (last_hidden_state * expanded_mask).sum(dim=1) - num_tokens = expanded_mask.sum(dim=1).clamp(min=1) - pooled_embeds = sum_embeds / num_tokens + last_hidden_state = outputs.hidden_states[-1] + expanded_mask = attention_mask.unsqueeze(-1).expand_as(last_hidden_state).float() + sum_embeds = (last_hidden_state * expanded_mask).sum(dim=1) + num_tokens = expanded_mask.sum(dim=1).clamp(min=1) + pooled_embeds = sum_embeds / num_tokens return prompt_embeds, pooled_embeds From 274d9b3a74e54d2ad06acc45e695cac3668c6ef7 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sat, 7 Mar 2026 02:52:25 +0100 Subject: [PATCH 16/24] fix(model_manager): detect Flux 2 Klein LoRAs in Kohya format with transformer-only keys (#8938) LoRAs trained with musubi-tuner (and potentially other trainers) that only target transformer blocks (double_blocks/single_blocks) without embedding layers (txt_in/vector_in/context_embedder) were incorrectly classified as Flux 1. Add fallback detection using attention projection hidden_size and MLP ratio from transformer block tensors Co-authored-by: Lincoln Stein --- .../backend/model_manager/configs/lora.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/invokeai/backend/model_manager/configs/lora.py b/invokeai/backend/model_manager/configs/lora.py index dcacf8d492..1619c9d6f0 100644 --- a/invokeai/backend/model_manager/configs/lora.py +++ b/invokeai/backend/model_manager/configs/lora.py @@ -239,6 +239,52 @@ def _is_flux2_lora_state_dict(state_dict: dict[str | int, Any]) -> bool: if in_dim is not None: return in_dim in _FLUX2_VEC_IN_DIMS + # Kohya format: check transformer block dimensions (hidden_size and MLP ratio). + # This handles LoRAs that only target transformer blocks (no txt_in/vector_in/context_embedder). + # Klein 9B has hidden_size=4096 (vs 3072 for FLUX.1 and Klein 4B). + # Klein 4B has same hidden_size as FLUX.1 (3072) but different mlp_ratio (6 vs 4). + kohya_hidden_size: int | None = None + for key in state_dict: + if not isinstance(key, str): + continue + if not key.startswith("lora_unet_"): + continue + + # Check img_attn_proj hidden_size + if "_img_attn_proj." in key and key.endswith("lora_down.weight"): + kohya_hidden_size = state_dict[key].shape[1] + if kohya_hidden_size != _FLUX1_HIDDEN_SIZE: + return True + break + # LoKR variant + elif "_img_attn_proj." in key and key.endswith((".lokr_w1", ".lokr_w1_b")): + layer_prefix = key.rsplit(".", 1)[0] + in_dim = _lokr_in_dim(state_dict, layer_prefix) + if in_dim is not None: + if in_dim != _FLUX1_HIDDEN_SIZE: + return True + kohya_hidden_size = in_dim + break + + # Kohya format: hidden_size matches FLUX.1. Check MLP ratio to distinguish Klein 4B. + # Klein 4B uses mlp_ratio=6 (ffn_dim=18432), FLUX.1 uses mlp_ratio=4 (ffn_dim=12288). + if kohya_hidden_size == _FLUX1_HIDDEN_SIZE: + for key in state_dict: + if not isinstance(key, str): + continue + if key.startswith("lora_unet_") and "_img_mlp_0." in key and key.endswith("lora_up.weight"): + ffn_dim = state_dict[key].shape[0] + if ffn_dim != kohya_hidden_size * _FLUX1_MLP_RATIO: + return True + break + # LoKR variant + if key.startswith("lora_unet_") and "_img_mlp_0." in key and key.endswith((".lokr_w1", ".lokr_w1_a")): + layer_prefix = key.rsplit(".", 1)[0] + out_dim = _lokr_out_dim(state_dict, layer_prefix) + if out_dim is not None and out_dim != kohya_hidden_size * _FLUX1_MLP_RATIO: + return True + break + return False @@ -421,6 +467,33 @@ def _get_flux2_lora_variant(state_dict: dict[str | int, Any]) -> Flux2VariantTyp return Flux2VariantType.Klein9B return None + # Kohya format: check transformer block dimensions (hidden_size from img_attn_proj). + # This handles LoRAs that only target transformer blocks (no txt_in/vector_in/context_embedder). + for key in state_dict: + if not isinstance(key, str): + continue + if not key.startswith("lora_unet_"): + continue + + # Check img_attn_proj hidden_size + if "_img_attn_proj." in key and key.endswith("lora_down.weight"): + dim = state_dict[key].shape[1] + if dim == KLEIN_4B_HIDDEN_SIZE: + return Flux2VariantType.Klein4B + if dim == KLEIN_9B_HIDDEN_SIZE: + return Flux2VariantType.Klein9B + return None + # LoKR variant + elif "_img_attn_proj." in key and key.endswith((".lokr_w1", ".lokr_w1_b")): + layer_prefix = key.rsplit(".", 1)[0] + in_dim = _lokr_in_dim(state_dict, layer_prefix) + if in_dim is not None: + if in_dim == KLEIN_4B_HIDDEN_SIZE: + return Flux2VariantType.Klein4B + if in_dim == KLEIN_9B_HIDDEN_SIZE: + return Flux2VariantType.Klein9B + return None + return None From b8b679816705ee2e513fee0c76ce319c612fdc03 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sat, 7 Mar 2026 02:56:30 +0100 Subject: [PATCH 17/24] ui: translations update from weblate (#8946) * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2205 of 2250 strings) Co-authored-by: Riccardo Giovanetti Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ Translation: InvokeAI/Web UI * translationBot(ui): update translation files Updated by "Remove blank strings" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI * translationBot(ui): update translation (Italian) Currently translated at 97.8% (2210 of 2259 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.8% (2224 of 2272 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 98.1% (2252 of 2295 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2264 of 2309 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Russian) Currently translated at 60.7% (1419 of 2334 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ru/ * translationBot(ui): update translation (Italian) Currently translated at 98.1% (2290 of 2334 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.7% (2319 of 2372 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.7% (2327 of 2380 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ --------- Co-authored-by: Riccardo Giovanetti Co-authored-by: DustyShoe --- invokeai/frontend/web/public/locales/it.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index 1134725c85..c0a6f0473d 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -836,7 +836,15 @@ "backendDisconnected": "Backend disconnesso", "cancelAll": "Annulla tutto", "cancelAllTooltip": "Annulla tutti i download attivi", - "selectModelToView": "Seleziona un modello per visualizzarne i dettagli" + "selectModelToView": "Seleziona un modello per visualizzarne i dettagli", + "exportSettings": "Impostazioni di esportazione", + "importSettings": "Impostazioni di importazione", + "settingsExported": "Impostazioni del modello esportate", + "settingsImported": "Impostazioni del modello importate", + "settingsImportedPartial": "Impostazioni del modello parzialmente importate. Le impostazioni incompatibili sono state ignorate: {{fields}}", + "settingsImportFailed": "Impossibile importare le impostazioni del modello", + "settingsImportIncompatible": "Il file delle impostazioni non contiene impostazioni compatibili per questo tipo di modello", + "settingsImportInvalidFile": "File di impostazioni non valido" }, "parameters": { "images": "Immagini", From 62b7c7a6e8e29a7e51533a5dd3371b6adb9502f2 Mon Sep 17 00:00:00 2001 From: Jonathan <34005131+JPPhoto@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:07:14 -0600 Subject: [PATCH 18/24] Added SQL injection tests (#8873) * Added SQL injection tests * Updated tests after multi-user merge * ruff:format --------- Co-authored-by: Lincoln Stein --- .../services/test_sql_injection_protection.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/app/services/test_sql_injection_protection.py diff --git a/tests/app/services/test_sql_injection_protection.py b/tests/app/services/test_sql_injection_protection.py new file mode 100644 index 0000000000..4efd7b0b8d --- /dev/null +++ b/tests/app/services/test_sql_injection_protection.py @@ -0,0 +1,64 @@ +import pytest + +from invokeai.app.services.board_records.board_records_common import ( + BoardRecordNotFoundException, + BoardRecordOrderBy, +) +from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.backend.util.logging import InvokeAILogger +from tests.fixtures.sqlite_database import create_mock_sqlite_database + + +def _create_board_storage() -> SqliteBoardRecordStorage: + config = InvokeAIAppConfig(use_memory_db=True) + db = create_mock_sqlite_database(config=config, logger=InvokeAILogger.get_logger()) + return SqliteBoardRecordStorage(db=db) + + +def test_sql_injection_payload_in_board_name_is_stored_as_plain_text() -> None: + storage = _create_board_storage() + + payload = "name'); DROP TABLE boards; --" + injected_board = storage.save(payload, "0") + + fetched = storage.get(injected_board.board_id) + assert fetched.board_name == payload + + another_board = storage.save("safe board", "0") + assert storage.get(another_board.board_id).board_name == "safe board" + + +def test_sql_injection_payload_in_board_id_does_not_bypass_where_clause() -> None: + storage = _create_board_storage() + + storage.save("first board", "0") + storage.save("second board", "0") + + payload = "does-not-exist' OR '1'='1" + + with pytest.raises(BoardRecordNotFoundException): + storage.get(payload) + + +def test_sql_injection_payload_in_delete_does_not_delete_other_rows() -> None: + storage = _create_board_storage() + + first = storage.save("first board", "0") + second = storage.save("second board", "0") + + payload = f"{first.board_id}' OR '1'='1" + storage.delete(payload) + + remaining = storage.get_many( + order_by=BoardRecordOrderBy.CreatedAt, + direction=SQLiteDirection.Ascending, + limit=10, + offset=0, + include_archived=True, + user_id="0", + is_admin=True, + ) + + assert {board.board_id for board in remaining.items} == {first.board_id, second.board_id} From 2d1dbceae52eea1ac7106d251752f27a14f99e8a Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 8 Mar 2026 16:49:15 -0400 Subject: [PATCH 19/24] Add user management UI for admin and regular users (#106) (#8937) * Add user management UI for admin and regular users (#106) * Add user management UI and backend API endpoints Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix user management feedback: cancel/back navigation, system user filter, tooltip fix Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Make Back button on User Management page more prominent Co-authored-by: lstein <111189+lstein@users.noreply.github.com> * chore(frontend): typegen --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Lincoln Stein * Add Confirm Password field to My Profile password change form (#110) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Co-authored-by: Alexander Eichhorn --- invokeai/app/api/routers/auth.py | 272 +++++++- invokeai/app/services/users/users_base.py | 9 + invokeai/app/services/users/users_default.py | 7 + invokeai/frontend/web/public/locales/en.json | 77 ++- .../frontend/web/src/app/components/App.tsx | 30 + .../auth/components/UserManagement.tsx | 640 ++++++++++++++++++ .../src/features/auth/components/UserMenu.tsx | 18 +- .../features/auth/components/UserProfile.tsx | 390 +++++++++++ .../web/src/services/api/endpoints/auth.ts | 83 ++- .../frontend/web/src/services/api/index.ts | 1 + .../frontend/web/src/services/api/schema.ts | 429 +++++++++++- 11 files changed, 1932 insertions(+), 24 deletions(-) create mode 100644 invokeai/frontend/web/src/features/auth/components/UserManagement.tsx create mode 100644 invokeai/frontend/web/src/features/auth/components/UserProfile.tsx diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index 11f2bacdc5..2e7e49c41e 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -1,15 +1,22 @@ """Authentication endpoints.""" +import secrets +import string from datetime import timedelta from typing import Annotated -from fastapi import APIRouter, Body, HTTPException, status +from fastapi import APIRouter, Body, HTTPException, Path, status from pydantic import BaseModel, Field, field_validator -from invokeai.app.api.auth_dependencies import CurrentUser +from invokeai.app.api.auth_dependencies import AdminUser, CurrentUser from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.auth.token_service import TokenData, create_access_token -from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, validate_email_with_special_domains +from invokeai.app.services.users.users_common import ( + UserCreateRequest, + UserDTO, + UserUpdateRequest, + validate_email_with_special_domains, +) auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"]) @@ -246,3 +253,262 @@ async def setup_admin( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e return SetupResponse(success=True, user=user) + + +# --------------------------------------------------------------------------- +# User management models +# --------------------------------------------------------------------------- + +_PASSWORD_ALPHABET = string.ascii_letters + string.digits + string.punctuation + + +class AdminUserCreateRequest(BaseModel): + """Request body for admin to create a new user.""" + + email: str = Field(description="User email address") + display_name: str | None = Field(default=None, description="Display name") + password: str = Field(description="User password") + is_admin: bool = Field(default=False, description="Whether user should have admin privileges") + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + + +class AdminUserUpdateRequest(BaseModel): + """Request body for admin to update any user.""" + + display_name: str | None = Field(default=None, description="Display name") + password: str | None = Field(default=None, description="New password") + is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges") + is_active: bool | None = Field(default=None, description="Whether user account should be active") + + +class UserProfileUpdateRequest(BaseModel): + """Request body for a user to update their own profile.""" + + display_name: str | None = Field(default=None, description="New display name") + current_password: str | None = Field(default=None, description="Current password (required when changing password)") + new_password: str | None = Field(default=None, description="New password") + + +class GeneratePasswordResponse(BaseModel): + """Response containing a generated password.""" + + password: str = Field(description="Generated strong password") + + +# --------------------------------------------------------------------------- +# User management endpoints +# --------------------------------------------------------------------------- + + +@auth_router.get("/generate-password", response_model=GeneratePasswordResponse) +async def generate_password( + current_user: CurrentUser, +) -> GeneratePasswordResponse: + """Generate a strong random password. + + Returns a cryptographically secure random password of 16 characters + containing uppercase, lowercase, digits, and punctuation. + """ + # Ensure the generated password always meets strength requirements: + # at least one uppercase, one lowercase, one digit, one special char. + while True: + password = "".join(secrets.choice(_PASSWORD_ALPHABET) for _ in range(16)) + if ( + any(c.isupper() for c in password) + and any(c.islower() for c in password) + and any(c.isdigit() for c in password) + ): + return GeneratePasswordResponse(password=password) + + +@auth_router.get("/users", response_model=list[UserDTO]) +async def list_users( + current_user: AdminUser, +) -> list[UserDTO]: + """List all users. Requires admin privileges. + + The internal 'system' user (created for backward compatibility) is excluded + from the results since it cannot be managed through this interface. + + Returns: + List of all real users (system user excluded) + """ + user_service = ApiDependencies.invoker.services.users + return [u for u in user_service.list_users() if u.user_id != "system"] + + +@auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED) +async def create_user( + request: Annotated[AdminUserCreateRequest, Body(description="New user details")], + current_user: AdminUser, +) -> UserDTO: + """Create a new user. Requires admin privileges. + + Args: + request: New user details + + Returns: + The created user + + Raises: + HTTPException: 400 if email already exists or password is weak + """ + user_service = ApiDependencies.invoker.services.users + try: + user_data = UserCreateRequest( + email=request.email, + display_name=request.display_name, + password=request.password, + is_admin=request.is_admin, + ) + return user_service.create(user_data) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@auth_router.get("/users/{user_id}", response_model=UserDTO) +async def get_user( + user_id: Annotated[str, Path(description="User ID")], + current_user: AdminUser, +) -> UserDTO: + """Get a user by ID. Requires admin privileges. + + Args: + user_id: The user ID + + Returns: + The user + + Raises: + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.get(user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +@auth_router.patch("/users/{user_id}", response_model=UserDTO) +async def update_user( + user_id: Annotated[str, Path(description="User ID")], + request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")], + current_user: AdminUser, +) -> UserDTO: + """Update a user. Requires admin privileges. + + Args: + user_id: The user ID + request: Fields to update + + Returns: + The updated user + + Raises: + HTTPException: 400 if password is weak + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + try: + changes = UserUpdateRequest( + display_name=request.display_name, + password=request.password, + is_admin=request.is_admin, + is_active=request.is_active, + ) + return user_service.update(user_id, changes) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: Annotated[str, Path(description="User ID")], + current_user: AdminUser, +) -> None: + """Delete a user. Requires admin privileges. + + Admins can delete any user including other admins, but cannot delete the last + remaining admin. + + Args: + user_id: The user ID + + Raises: + HTTPException: 400 if attempting to delete the last admin + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.get(user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # Prevent deleting the last active admin + if user.is_admin and user.is_active and user_service.count_admins() <= 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete the last administrator", + ) + + try: + user_service.delete(user_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@auth_router.patch("/me", response_model=UserDTO) +async def update_current_user( + request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")], + current_user: CurrentUser, +) -> UserDTO: + """Update the current user's own profile. + + To change the password, both ``current_password`` and ``new_password`` must + be provided. The current password is verified before the change is applied. + + Args: + request: Profile fields to update + current_user: The authenticated user + + Returns: + The updated user + + Raises: + HTTPException: 400 if current password is incorrect or new password is weak + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + + # Verify current password when attempting a password change + if request.new_password is not None: + if not request.current_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is required to set a new password", + ) + + # Re-authenticate to verify the current password + user = user_service.get(current_user.user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + authenticated = user_service.authenticate(user.email, request.current_password) + if authenticated is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + try: + changes = UserUpdateRequest( + display_name=request.display_name, + password=request.new_password, + ) + return user_service.update(current_user.user_id, changes) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py index 6587a2aa3a..5ad66c5983 100644 --- a/invokeai/app/services/users/users_base.py +++ b/invokeai/app/services/users/users_base.py @@ -124,3 +124,12 @@ class UserServiceBase(ABC): List of users """ pass + + @abstractmethod + def count_admins(self) -> int: + """Count active admin users. + + Returns: + The number of active admin users + """ + pass diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 36ccec9e7e..506ae937f0 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -249,3 +249,10 @@ class UserService(UserServiceBase): ) for row in rows ] + + def count_admins(self) -> int: + """Count active admin users.""" + with self._db.transaction() as cursor: + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE") + row = cursor.fetchone() + return int(row[0]) if row else 0 diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 4fcf0786b8..617c434157 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -51,7 +51,58 @@ "userMenu": "User Menu", "admin": "Admin", "logout": "Logout", - "adminOnlyFeature": "This feature is only available to administrators." + "adminOnlyFeature": "This feature is only available to administrators.", + "profile": { + "menuItem": "My Profile", + "title": "My Profile", + "email": "Email", + "emailReadOnly": "Email address cannot be changed", + "displayName": "Display Name", + "displayNamePlaceholder": "Your name", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "currentPasswordPlaceholder": "Current password", + "newPassword": "New Password", + "newPasswordPlaceholder": "New password", + "confirmPassword": "Confirm New Password", + "confirmPasswordPlaceholder": "Confirm new password", + "passwordsDoNotMatch": "Passwords do not match", + "saveSuccess": "Profile updated successfully", + "saveFailed": "Failed to save profile. Please try again." + }, + "userManagement": { + "menuItem": "User Management", + "title": "User Management", + "email": "Email", + "emailPlaceholder": "user@example.com", + "displayName": "Display Name", + "displayNamePlaceholder": "Display name", + "password": "Password", + "passwordPlaceholder": "Password", + "newPassword": "New Password", + "newPasswordPlaceholder": "Leave blank to keep current password", + "role": "Role", + "status": "Status", + "actions": "Actions", + "isAdmin": "Administrator", + "user": "User", + "you": "You", + "createUser": "Create User", + "editUser": "Edit User", + "deleteUser": "Delete User", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "generatePassword": "Generate Strong Password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "activate": "Activate", + "deactivate": "Deactivate", + "saveFailed": "Failed to save user. Please try again.", + "deleteFailed": "Failed to delete user. Please try again.", + "loadFailed": "Failed to load users.", + "back": "Back", + "cannotDeleteSelf": "You cannot delete your own account", + "cannotDeactivateSelf": "You cannot deactivate your own account" + } }, "boards": { "addBoard": "Add Board", @@ -2423,10 +2474,14 @@ "text": { "font": "Font", "size": "Size", - "lineHeight": "Spacing", - "lineHeightDense": "Dense", - "lineHeightNormal": "Normal", - "lineHeightSpacious": "Spacious" + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "alignLeft": "Align Left", + "alignCenter": "Align Center", + "alignRight": "Align Right", + "px": "px" }, "newCanvasFromImage": "New Canvas from Image", "newImg2ImgCanvasFromImage": "New Img2Img from Image", @@ -2591,18 +2646,6 @@ "colorPicker": "Color Picker", "text": "Text" }, - "text": { - "font": "Font", - "size": "Size", - "bold": "Bold", - "italic": "Italic", - "underline": "Underline", - "strikethrough": "Strikethrough", - "alignLeft": "Align Left", - "alignCenter": "Align Center", - "alignRight": "Align Right", - "px": "px" - }, "filter": { "filter": "Filter", "filters": "Filters", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 678acc7de1..0f9fb5292b 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -7,8 +7,11 @@ import Loading from 'common/components/Loading/Loading'; import { AdministratorSetup } from 'features/auth/components/AdministratorSetup'; import { LoginPage } from 'features/auth/components/LoginPage'; import { ProtectedRoute } from 'features/auth/components/ProtectedRoute'; +import { UserManagement } from 'features/auth/components/UserManagement'; +import { UserProfile } from 'features/auth/components/UserProfile'; import { AppContent } from 'features/ui/components/AppContent'; import { navigationApi } from 'features/ui/layouts/navigation-api'; +import type { ReactNode } from 'react'; import { memo, useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { Route, Routes, useNavigate } from 'react-router-dom'; @@ -67,6 +70,13 @@ const SetupChecker = () => { return null; }; +/** Full-page wrapper for user management / profile pages rendered inside the protected area */ +const FullPageWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + const App = () => { return ( @@ -75,6 +85,26 @@ const App = () => { } /> } /> } /> + + + + + + } + /> + + + + + + } + /> string +): { isValid: boolean; message: string } => { + if (password.length === 0) { + return { isValid: true, message: '' }; + } + if (password.length < 8) { + return { isValid: false, message: t('auth.setup.passwordTooShort') }; + } + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + if (!hasUpper || !hasLower || !hasDigit) { + return { isValid: false, message: t('auth.setup.passwordMissingRequirements') }; + } + return { isValid: true, message: '' }; +}; + +const FORM_GRID_COLUMNS = '120px 1fr'; + +// --------------------------------------------------------------------------- +// Create / Edit user modal +// --------------------------------------------------------------------------- + +type UserFormModalProps = { + isOpen: boolean; + onClose: () => void; + /** When provided, the modal operates in "edit" mode for the given user */ + editUser?: UserDTO | null; +}; + +const UserFormModal = memo(({ isOpen, onClose, editUser }: UserFormModalProps) => { + const { t } = useTranslation(); + const isEdit = !!editUser; + + const [email, setEmail] = useState(editUser?.email ?? ''); + const [displayName, setDisplayName] = useState(editUser?.display_name ?? ''); + const [password, setPassword] = useState(''); + const [isAdmin, setIsAdmin] = useState(editUser?.is_admin ?? false); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + + const [createUser, { isLoading: isCreating }] = useCreateUserMutation(); + const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation(); + const [triggerGeneratePassword] = useLazyGeneratePasswordQuery(); + + const isLoading = isCreating || isUpdating; + const passwordValidation = validatePasswordStrength(password, t); + + const handleGeneratePassword = useCallback(async () => { + try { + const result = await triggerGeneratePassword().unwrap(); + setPassword(result.password); + setShowPassword(true); + } catch { + // ignore + } + }, [triggerGeneratePassword]); + + const toggleShowPassword = useCallback(() => { + setShowPassword((v) => !v); + }, []); + + const handleEmailChange = useCallback((e: ChangeEvent) => { + setEmail(e.target.value); + }, []); + + const handleDisplayNameChange = useCallback((e: ChangeEvent) => { + setDisplayName(e.target.value); + }, []); + + const handlePasswordChange = useCallback((e: ChangeEvent) => { + setPassword(e.target.value); + }, []); + + const handleIsAdminChange = useCallback((e: ChangeEvent) => { + setIsAdmin(e.target.checked); + }, []); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + if (!isEdit && (!password || !passwordValidation.isValid)) { + return; + } + if (isEdit && password && !passwordValidation.isValid) { + return; + } + + try { + if (isEdit && editUser) { + const updateData: Parameters[0]['data'] = { + display_name: displayName || null, + is_admin: isAdmin, + }; + if (password) { + updateData.password = password; + } + await updateUser({ + userId: editUser.user_id, + data: updateData, + }).unwrap(); + } else { + await createUser({ + email, + display_name: displayName || null, + password, + is_admin: isAdmin, + }).unwrap(); + } + onClose(); + } catch (err) { + const detail = + err && typeof err === 'object' && 'data' in err && typeof (err as { data: unknown }).data === 'object' + ? ((err as { data: { detail?: string } }).data?.detail ?? t('auth.userManagement.saveFailed')) + : t('auth.userManagement.saveFailed'); + setError(detail); + } + }, + [ + isEdit, + editUser, + email, + displayName, + password, + isAdmin, + passwordValidation.isValid, + createUser, + updateUser, + onClose, + t, + ] + ); + + // Reset local state when modal closes + const handleClose = useCallback(() => { + setEmail(editUser?.email ?? ''); + setDisplayName(editUser?.display_name ?? ''); + setPassword(''); + setIsAdmin(editUser?.is_admin ?? false); + setShowPassword(false); + setError(null); + onClose(); + }, [editUser, onClose]); + + return ( + + + +
+ {isEdit ? t('auth.userManagement.editUser') : t('auth.userManagement.createUser')} + + + + {!isEdit && ( + + + + + {t('auth.userManagement.email')} + + + + + + + + )} + + + + + + {t('auth.userManagement.displayName')} + + + + + + + + + 0 && !passwordValidation.isValid} isRequired={!isEdit}> + + + + {isEdit ? t('auth.userManagement.newPassword') : t('auth.userManagement.password')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowPassword} + tabIndex={-1} + /> + + + + {password.length > 0 && !passwordValidation.isValid && ( + {passwordValidation.message} + )} + + + + + + + + + + + + + {t('auth.userManagement.isAdmin')} + + + + {error && ( + + {error} + + )} + + + + + + + +
+
+ ); +}); +UserFormModal.displayName = 'UserFormModal'; + +// --------------------------------------------------------------------------- +// Delete confirmation modal +// --------------------------------------------------------------------------- + +type DeleteUserModalProps = { + isOpen: boolean; + onClose: () => void; + user: UserDTO | null; +}; + +const DeleteUserModal = memo(({ isOpen, onClose, user }: DeleteUserModalProps) => { + const { t } = useTranslation(); + const [deleteUser, { isLoading }] = useDeleteUserMutation(); + const [error, setError] = useState(null); + + const handleDelete = useCallback(async () => { + if (!user) { + return; + } + setError(null); + try { + await deleteUser(user.user_id).unwrap(); + onClose(); + } catch (err) { + const detail = + err && typeof err === 'object' && 'data' in err && typeof (err as { data: unknown }).data === 'object' + ? ((err as { data: { detail?: string } }).data?.detail ?? t('auth.userManagement.deleteFailed')) + : t('auth.userManagement.deleteFailed'); + setError(detail); + } + }, [user, deleteUser, onClose, t]); + + const handleClose = useCallback(() => { + setError(null); + onClose(); + }, [onClose]); + + return ( + + + + {t('auth.userManagement.deleteUser')} + + + + {t('auth.userManagement.deleteConfirm', { + name: user?.display_name ?? user?.email ?? '', + })} + + {error && ( + + {error} + + )} + + + + + + + + ); +}); +DeleteUserModal.displayName = 'DeleteUserModal'; + +// --------------------------------------------------------------------------- +// Inline active/inactive toggle +// Wrapping the Switch in a Box lets the Tooltip track mouse-enter/leave +// correctly; without it the tooltip may not dismiss on mouse-out. +// --------------------------------------------------------------------------- + +const UserStatusToggle = memo(({ user, isCurrentUser }: { user: UserDTO; isCurrentUser: boolean }) => { + const { t } = useTranslation(); + const [updateUser, { isLoading }] = useUpdateUserMutation(); + + const handleChange = useCallback( + async (e: ChangeEvent) => { + await updateUser({ userId: user.user_id, data: { is_active: e.target.checked } }) + .unwrap() + .catch(() => null); + }, + [user.user_id, updateUser] + ); + + const tooltipLabel = isCurrentUser + ? t('auth.userManagement.cannotDeactivateSelf') + : user.is_active + ? t('auth.userManagement.deactivate') + : t('auth.userManagement.activate'); + + return ( + + + + + + ); +}); +UserStatusToggle.displayName = 'UserStatusToggle'; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const UserManagement = memo(() => { + const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); + const navigate = useNavigate(); + const { data: users, isLoading, error } = useListUsersQuery(); + + const createModal = useDisclosure(); + const editModal = useDisclosure(); + const deleteModal = useDisclosure(); + + const [selectedUser, setSelectedUser] = useState(null); + + const handleBack = useCallback(() => { + navigate(-1); + }, [navigate]); + + const handleEdit = useCallback( + (user: UserDTO) => { + setSelectedUser(user); + editModal.onOpen(); + }, + [editModal] + ); + + const handleDelete = useCallback( + (user: UserDTO) => { + setSelectedUser(user); + deleteModal.onOpen(); + }, + [deleteModal] + ); + + const handleEditClose = useCallback(() => { + editModal.onClose(); + setSelectedUser(null); + }, [editModal]); + + const handleDeleteClose = useCallback(() => { + deleteModal.onClose(); + setSelectedUser(null); + }, [deleteModal]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {t('auth.userManagement.loadFailed')} +
+ ); + } + + return ( + + + + + {t('auth.userManagement.title')} + + + + + + + + + + + + + + + + + {(users ?? []).map((user) => ( + + ))} + +
{t('auth.userManagement.email')}{t('auth.userManagement.displayName')}{t('auth.userManagement.role')}{t('auth.userManagement.status')}{t('auth.userManagement.actions')}
+
+ + {/* Create user modal */} + + + {/* Edit user modal */} + + + {/* Delete confirmation modal */} + +
+ ); +}); +UserManagement.displayName = 'UserManagement'; + +// --------------------------------------------------------------------------- +// User table row +// --------------------------------------------------------------------------- + +type UserRowProps = { + user: UserDTO; + isCurrentUser: boolean; + onEdit: (user: UserDTO) => void; + onDelete: (user: UserDTO) => void; +}; + +const UserRow = memo(({ user, isCurrentUser, onEdit, onDelete }: UserRowProps) => { + const { t } = useTranslation(); + + const handleEdit = useCallback(() => { + onEdit(user); + }, [user, onEdit]); + + const handleDelete = useCallback(() => { + onDelete(user); + }, [user, onDelete]); + + return ( + + + {user.email} + {isCurrentUser && ( + + {t('auth.userManagement.you')} + + )} + + + {user.display_name ?? '—'} + + + {user.is_admin ? ( + {t('auth.admin')} + ) : ( + {t('auth.userManagement.user')} + )} + + + + + + + + } + variant="ghost" + size="sm" + onClick={handleEdit} + /> + + + } + variant="ghost" + size="sm" + colorScheme="error" + isDisabled={isCurrentUser} + onClick={handleDelete} + /> + + + + + ); +}); +UserRow.displayName = 'UserRow'; diff --git a/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx b/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx index 970c1d7533..d8f598f996 100644 --- a/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx +++ b/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx @@ -3,7 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { logout, selectCurrentUser } from 'features/auth/store/authSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiSignOutBold, PiUserBold } from 'react-icons/pi'; +import { PiGearBold, PiSignOutBold, PiUserBold, PiUsersBold } from 'react-icons/pi'; import { useNavigate } from 'react-router-dom'; import { useLogoutMutation } from 'services/api/endpoints/auth'; @@ -28,6 +28,14 @@ export const UserMenu = memo(() => { }); }, [dispatch, navigate, logoutMutation]); + const handleProfile = useCallback(() => { + navigate('/profile'); + }, [navigate]); + + const handleUserManagement = useCallback(() => { + navigate('/admin/users'); + }, [navigate]); + if (!user) { return null; } @@ -60,6 +68,14 @@ export const UserMenu = memo(() => { )}
+ } onClick={handleProfile}> + {t('auth.profile.menuItem')} + + {user.is_admin && ( + } onClick={handleUserManagement}> + {t('auth.userManagement.menuItem')} + + )} } onClick={handleLogout}> {t('auth.logout')} diff --git a/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx new file mode 100644 index 0000000000..4504698f0e --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx @@ -0,0 +1,390 @@ +import { + Box, + Button, + Center, + Flex, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Grid, + GridItem, + Heading, + IconButton, + Input, + InputGroup, + InputRightElement, + Spinner, + Text, + Tooltip, + VStack, +} from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectAuthToken, selectCurrentUser, setCredentials } from 'features/auth/store/authSlice'; +import type { ChangeEvent, FormEvent } from 'react'; +import { memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiEyeBold, PiEyeSlashBold, PiLightningFill } from 'react-icons/pi'; +import { useNavigate } from 'react-router-dom'; +import { useLazyGeneratePasswordQuery, useUpdateCurrentUserMutation } from 'services/api/endpoints/auth'; + +const validatePasswordStrength = ( + password: string, + t: (key: string) => string +): { isValid: boolean; message: string } => { + if (password.length === 0) { + return { isValid: true, message: '' }; + } + if (password.length < 8) { + return { isValid: false, message: t('auth.setup.passwordTooShort') }; + } + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + if (!hasUpper || !hasLower || !hasDigit) { + return { isValid: false, message: t('auth.setup.passwordMissingRequirements') }; + } + return { isValid: true, message: '' }; +}; + +const PASSWORD_GRID_COLUMNS = '180px 1fr'; + +export const UserProfile = memo(() => { + const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); + const currentToken = useAppSelector(selectAuthToken); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const [displayName, setDisplayName] = useState(currentUser?.display_name ?? ''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const [updateCurrentUser, { isLoading }] = useUpdateCurrentUserMutation(); + const [triggerGeneratePassword] = useLazyGeneratePasswordQuery(); + + const newPasswordValidation = validatePasswordStrength(newPassword, t); + + const isPasswordChangeAttempted = newPassword.length > 0 || currentPassword.length > 0; + const passwordsMatch = newPassword.length > 0 && newPassword === confirmPassword; + const isPasswordChangeValid = + !isPasswordChangeAttempted || (currentPassword.length > 0 && newPasswordValidation.isValid && passwordsMatch); + + const handleCancel = useCallback(() => { + navigate(-1); + }, [navigate]); + + const handleGeneratePassword = useCallback(async () => { + try { + const result = await triggerGeneratePassword().unwrap(); + setNewPassword(result.password); + setConfirmPassword(result.password); + setShowNewPassword(true); + setShowConfirmPassword(true); + } catch { + // ignore + } + }, [triggerGeneratePassword]); + + const toggleShowCurrentPassword = useCallback(() => { + setShowCurrentPassword((v) => !v); + }, []); + + const toggleShowNewPassword = useCallback(() => { + setShowNewPassword((v) => !v); + }, []); + + const toggleShowConfirmPassword = useCallback(() => { + setShowConfirmPassword((v) => !v); + }, []); + + const handleDisplayNameChange = useCallback((e: ChangeEvent) => { + setDisplayName(e.target.value); + }, []); + + const handleCurrentPasswordChange = useCallback((e: ChangeEvent) => { + setCurrentPassword(e.target.value); + }, []); + + const handleNewPasswordChange = useCallback((e: ChangeEvent) => { + setNewPassword(e.target.value); + }, []); + + const handleConfirmPasswordChange = useCallback((e: ChangeEvent) => { + setConfirmPassword(e.target.value); + }, []); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setErrorMessage(null); + + if (!isPasswordChangeValid) { + return; + } + + try { + const updatePayload: Parameters[0] = { + display_name: displayName || null, + }; + if (newPassword) { + updatePayload.current_password = currentPassword; + updatePayload.new_password = newPassword; + } + const updatedUser = await updateCurrentUser(updatePayload).unwrap(); + + // Refresh the stored user info so the header reflects the new display name + if (currentToken) { + dispatch( + setCredentials({ + token: currentToken, + user: { + user_id: updatedUser.user_id, + email: updatedUser.email, + display_name: updatedUser.display_name ?? null, + is_admin: updatedUser.is_admin ?? false, + is_active: updatedUser.is_active ?? true, + }, + }) + ); + } + + // Navigate back after successful save + navigate(-1); + } catch (err) { + const detail = + err && typeof err === 'object' && 'data' in err && typeof (err as { data: unknown }).data === 'object' + ? ((err as { data: { detail?: string } }).data?.detail ?? t('auth.profile.saveFailed')) + : t('auth.profile.saveFailed'); + setErrorMessage(detail); + } + }, + [ + displayName, + currentPassword, + newPassword, + isPasswordChangeValid, + updateCurrentUser, + currentToken, + dispatch, + navigate, + t, + ] + ); + + if (!currentUser) { + return ( +
+ +
+ ); + } + + return ( + + + {t('auth.profile.title')} + + +
+ + {/* Email (read-only) */} + + {t('auth.profile.email')} + + {t('auth.profile.emailReadOnly')} + + + {/* Display name */} + + {t('auth.profile.displayName')} + + + + + + {t('auth.profile.changePassword')} + + + {/* Current password */} + 0}> + + + + {t('auth.profile.currentPassword')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowCurrentPassword} + tabIndex={-1} + /> + + + + + + + + {/* New password */} + 0 && !newPasswordValidation.isValid} mb={4}> + + + + {t('auth.profile.newPassword')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowNewPassword} + tabIndex={-1} + /> + + + + {newPassword.length > 0 && !newPasswordValidation.isValid && ( + {newPasswordValidation.message} + )} + + + + + {/* Confirm new password */} + 0 && !passwordsMatch} mb={4}> + + + + {t('auth.profile.confirmPassword')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowConfirmPassword} + tabIndex={-1} + /> + + + + {confirmPassword.length > 0 && !passwordsMatch && ( + {t('auth.profile.passwordsDoNotMatch')} + )} + + + + + {/* Generate password button – aligned with the input column */} + + + + + + + + + {errorMessage && ( + + {errorMessage} + + )} + + + + + + +
+
+ ); +}); +UserProfile.displayName = 'UserProfile'; diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts index ba81c08136..c7a8a8b1ff 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts @@ -35,6 +35,32 @@ type SetupStatusResponse = { multiuser_enabled: boolean; }; +export type UserDTO = components['schemas']['UserDTO']; + +type AdminUserCreateRequest = { + email: string; + display_name?: string | null; + password: string; + is_admin?: boolean; +}; + +type AdminUserUpdateRequest = { + display_name?: string | null; + password?: string | null; + is_admin?: boolean | null; + is_active?: boolean | null; +}; + +type UserProfileUpdateRequest = { + display_name?: string | null; + current_password?: string | null; + new_password?: string | null; +}; + +type GeneratePasswordResponse = { + password: string; +}; + export const authApi = api.injectEndpoints({ endpoints: (build) => ({ login: build.mutation({ @@ -67,8 +93,61 @@ export const authApi = api.injectEndpoints({ getSetupStatus: build.query({ query: () => 'api/v1/auth/status', }), + listUsers: build.query({ + query: () => 'api/v1/auth/users', + providesTags: ['UserList'], + }), + createUser: build.mutation({ + query: (data) => ({ + url: 'api/v1/auth/users', + method: 'POST', + body: data, + }), + invalidatesTags: ['UserList'], + }), + getUser: build.query({ + query: (userId) => `api/v1/auth/users/${userId}`, + providesTags: (_result, _error, userId) => [{ type: 'UserList', id: userId }], + }), + updateUser: build.mutation({ + query: ({ userId, data }) => ({ + url: `api/v1/auth/users/${userId}`, + method: 'PATCH', + body: data, + }), + invalidatesTags: ['UserList'], + }), + deleteUser: build.mutation({ + query: (userId) => ({ + url: `api/v1/auth/users/${userId}`, + method: 'DELETE', + }), + invalidatesTags: ['UserList'], + }), + updateCurrentUser: build.mutation({ + query: (data) => ({ + url: 'api/v1/auth/me', + method: 'PATCH', + body: data, + }), + }), + generatePassword: build.query({ + query: () => 'api/v1/auth/generate-password', + }), }), }); -export const { useLoginMutation, useLogoutMutation, useGetCurrentUserQuery, useSetupMutation, useGetSetupStatusQuery } = - authApi; +export const { + useLoginMutation, + useLogoutMutation, + useGetCurrentUserQuery, + useSetupMutation, + useGetSetupStatusQuery, + useListUsersQuery, + useCreateUserMutation, + useGetUserQuery, + useUpdateUserMutation, + useDeleteUserMutation, + useUpdateCurrentUserMutation, + useLazyGeneratePasswordQuery, +} = authApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 5b75d724e2..5be1aa2a67 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -58,6 +58,7 @@ const tagTypes = [ // especially related to the queue and generation. 'FetchOnReconnect', 'ClientState', + 'UserList', ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b605413787..52a318f816 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -111,7 +111,25 @@ export type paths = { delete?: never; options?: never; head?: never; - patch?: never; + /** + * Update Current User + * @description Update the current user's own profile. + * + * To change the password, both ``current_password`` and ``new_password`` must + * be provided. The current password is verified before the change is applied. + * + * Args: + * request: Profile fields to update + * current_user: The authenticated user + * + * Returns: + * The updated user + * + * Raises: + * HTTPException: 400 if current password is incorrect or new password is weak + * HTTPException: 404 if user not found + */ + patch: operations["update_current_user_api_v1_auth_me_patch"]; trace?: never; }; "/api/v1/auth/setup": { @@ -147,6 +165,126 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/auth/generate-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Generate Password + * @description Generate a strong random password. + * + * Returns a cryptographically secure random password of 16 characters + * containing uppercase, lowercase, digits, and punctuation. + */ + get: operations["generate_password_api_v1_auth_generate_password_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Users + * @description List all users. Requires admin privileges. + * + * The internal 'system' user (created for backward compatibility) is excluded + * from the results since it cannot be managed through this interface. + * + * Returns: + * List of all real users (system user excluded) + */ + get: operations["list_users_api_v1_auth_users_get"]; + put?: never; + /** + * Create User + * @description Create a new user. Requires admin privileges. + * + * Args: + * request: New user details + * + * Returns: + * The created user + * + * Raises: + * HTTPException: 400 if email already exists or password is weak + */ + post: operations["create_user_api_v1_auth_users_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get User + * @description Get a user by ID. Requires admin privileges. + * + * Args: + * user_id: The user ID + * + * Returns: + * The user + * + * Raises: + * HTTPException: 404 if user not found + */ + get: operations["get_user_api_v1_auth_users__user_id__get"]; + put?: never; + post?: never; + /** + * Delete User + * @description Delete a user. Requires admin privileges. + * + * Admins can delete any user including other admins, but cannot delete the last + * remaining admin. + * + * Args: + * user_id: The user ID + * + * Raises: + * HTTPException: 400 if attempting to delete the last admin + * HTTPException: 404 if user not found + */ + delete: operations["delete_user_api_v1_auth_users__user_id__delete"]; + options?: never; + head?: never; + /** + * Update User + * @description Update a user. Requires admin privileges. + * + * Args: + * user_id: The user ID + * request: Fields to update + * + * Returns: + * The updated user + * + * Raises: + * HTTPException: 400 if password is weak + * HTTPException: 404 if user not found + */ + patch: operations["update_user_api_v1_auth_users__user_id__patch"]; + trace?: never; + }; "/api/v1/utilities/dynamicprompts": { parameters: { query?: never; @@ -2360,6 +2498,59 @@ export type components = { */ type: "add"; }; + /** + * AdminUserCreateRequest + * @description Request body for admin to create a new user. + */ + AdminUserCreateRequest: { + /** + * Email + * @description User email address + */ + email: string; + /** + * Display Name + * @description Display name + */ + display_name?: string | null; + /** + * Password + * @description User password + */ + password: string; + /** + * Is Admin + * @description Whether user should have admin privileges + * @default false + */ + is_admin?: boolean; + }; + /** + * AdminUserUpdateRequest + * @description Request body for admin to update any user. + */ + AdminUserUpdateRequest: { + /** + * Display Name + * @description Display name + */ + display_name?: string | null; + /** + * Password + * @description New password + */ + password?: string | null; + /** + * Is Admin + * @description Whether user should have admin privileges + */ + is_admin?: boolean | null; + /** + * Is Active + * @description Whether user account should be active + */ + is_active?: boolean | null; + }; /** * Alpha Mask to Tensor * @description Convert a mask image to a tensor. Opaque regions are 1 and transparent regions are 0. @@ -10349,6 +10540,17 @@ export type components = { */ type: "freeu"; }; + /** + * GeneratePasswordResponse + * @description Response containing a generated password. + */ + GeneratePasswordResponse: { + /** + * Password + * @description Generated strong password + */ + password: string; + }; /** * Get Image Mask Bounding Box * @description Gets the bounding box of the given mask image. @@ -26672,6 +26874,27 @@ export type components = { */ last_login_at?: string | null; }; + /** + * UserProfileUpdateRequest + * @description Request body for a user to update their own profile. + */ + UserProfileUpdateRequest: { + /** + * Display Name + * @description New display name + */ + display_name?: string | null; + /** + * Current Password + * @description Current password (required when changing password) + */ + current_password?: string | null; + /** + * New Password + * @description New password + */ + new_password?: string | null; + }; /** VAEField */ VAEField: { /** @description Info to load vae submodel */ @@ -28518,6 +28741,39 @@ export interface operations { }; }; }; + update_current_user_api_v1_auth_me_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserProfileUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; setup_admin_api_v1_auth_setup_post: { parameters: { query?: never; @@ -28551,6 +28807,177 @@ export interface operations { }; }; }; + generate_password_api_v1_auth_generate_password_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeneratePasswordResponse"]; + }; + }; + }; + }; + list_users_api_v1_auth_users_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"][]; + }; + }; + }; + }; + create_user_api_v1_auth_users_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_api_v1_auth_users__user_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_user_api_v1_auth_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_user_api_v1_auth_users__user_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; parse_dynamicprompts: { parameters: { query?: never; From e74d8ab2bb32271a31e87314e09ca27812ad00e4 Mon Sep 17 00:00:00 2001 From: DustyShoe <38873282+DustyShoe@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:23:00 +0200 Subject: [PATCH 20/24] Fix(gallery): Re-add image browsing with arrow keys (#8874) * fix(gallery): restore arrow-key browsing and extract shared prev/next navigation * Added same behavior to Upscale mode and autofocus to gallery after using hotkeys Ctrl+Enter and Ctrl+Shift+Enter * restore arrow navigation focus flow across viewer states * fix(gallery): stabilize arrow-key browsing, remove viewer UI flicker, and optimize code --------- Co-authored-by: Lincoln Stein --- .../gallery/components/GalleryImageGrid.tsx | 49 +++++-- .../components/GalleryImageGridPaged.tsx | 1 + .../ImageViewer/CurrentImagePreview.tsx | 122 +++++++++++++++--- .../components/NextPrevItemButtons.tsx | 63 ++++----- .../components/useNextPrevItemNavigation.ts | 47 +++++++ .../web/src/features/queue/hooks/useInvoke.ts | 42 +++--- .../src/features/ui/layouts/navigation-api.ts | 81 +++++++++++- 7 files changed, 317 insertions(+), 88 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx index 3fb610498d..b4443b8789 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGrid.tsx @@ -13,6 +13,9 @@ import { } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { navigationApi } from 'features/ui/layouts/navigation-api'; +import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { MutableRefObject } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { @@ -80,22 +83,41 @@ const computeItemKey: GridComputeItemKey = (index, imageNam return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; }; +const canHandleGridArrowNavigation = ( + activeTab: ReturnType, + focusedRegion: ReturnType +) => { + if (navigationApi.isViewerArrowNavigationMode(activeTab)) { + // When gallery is not effectively available, viewer hotkeys own left/right navigation. + return false; + } + + if (focusedRegion === 'gallery' || focusedRegion === 'viewer') { + return true; + } + + // Fallback for tab-switch edge case: allow nav when viewer dock tab is active before first click. + return navigationApi.isDockviewPanelActive(activeTab, VIEWER_PANEL_ID); +}; + /** * Handles keyboard navigation for the gallery. */ const useKeyboardNavigation = ( - imageNames: string[], + navigationImageNames: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject ) => { const { dispatch, getState } = useAppStore(); + const activeTab = useAppSelector(selectActiveTab); const handleKeyDown = useCallback( (event: KeyboardEvent) => { - if (getFocusedRegion() !== 'gallery') { - // Only handle keyboard navigation when the gallery is focused + const focusedRegion = getFocusedRegion(); + if (!canHandleGridArrowNavigation(activeTab, focusedRegion)) { return; } + // Only handle arrow keys if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { return; @@ -112,7 +134,7 @@ const useKeyboardNavigation = ( return; } - if (imageNames.length === 0) { + if (navigationImageNames.length === 0) { return; } @@ -132,7 +154,7 @@ const useKeyboardNavigation = ( (selectImageToCompare(state) ?? selectLastSelectedItem(state)) : selectLastSelectedItem(state); - const currentIndex = getItemIndex(imageName ?? null, imageNames); + const currentIndex = getItemIndex(imageName ?? null, navigationImageNames); let newIndex = currentIndex; @@ -146,7 +168,7 @@ const useKeyboardNavigation = ( } break; case 'ArrowRight': - if (currentIndex < imageNames.length - 1) { + if (currentIndex < navigationImageNames.length - 1) { newIndex = currentIndex + 1; // } else { // // Wrap to first image @@ -163,16 +185,16 @@ const useKeyboardNavigation = ( break; case 'ArrowDown': // If no images below, stay on current image - if (currentIndex >= imageNames.length - imagesPerRow) { + if (currentIndex >= navigationImageNames.length - imagesPerRow) { newIndex = currentIndex; } else { - newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow); + newIndex = Math.min(navigationImageNames.length - 1, currentIndex + imagesPerRow); } break; } - if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) { - const newImageName = imageNames[newIndex]; + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < navigationImageNames.length) { + const newImageName = navigationImageNames[newIndex]; if (newImageName) { if (event.altKey) { dispatch(imageToCompareChanged(newImageName)); @@ -182,7 +204,7 @@ const useKeyboardNavigation = ( } } }, - [rootRef, virtuosoRef, imageNames, getState, dispatch] + [activeTab, rootRef, virtuosoRef, navigationImageNames, getState, dispatch] ); useRegisteredHotkeys({ @@ -316,13 +338,14 @@ const useStarImageHotkey = () => { type GalleryImageGridContentProps = { imageNames: string[]; + navigationImageNames?: string[]; isLoading: boolean; queryArgs: ListImageNamesQueryArgs; rootRef?: React.RefObject; }; export const GalleryImageGridContent = memo( - ({ imageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => { + ({ imageNames, navigationImageNames, isLoading, queryArgs, rootRef: rootRefProp }: GalleryImageGridContentProps) => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const internalRootRef = useRef(null); @@ -336,7 +359,7 @@ export const GalleryImageGridContent = memo( useStarImageHotkey(); useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); - useKeyboardNavigation(imageNames, virtuosoRef, rootRef); + useKeyboardNavigation(navigationImageNames ?? imageNames, virtuosoRef, rootRef); const scrollerRef = useScrollableGallery(rootRef); /* diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx index af6101d85a..c5b4fc405d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryImageGridPaged.tsx @@ -181,6 +181,7 @@ export const GalleryImageGridPaged = memo(() => { { + const activeTab = useAppSelector(selectActiveTab); + const selectedImageName = useAppSelector(selectLastSelectedItem); const shouldShowItemDetails = useAppSelector(selectShouldShowItemDetails); const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer); + const { goToPreviousImage, goToNextImage, isFetching } = useNextPrevItemNavigation(); const { onLoadImage, $progressEvent, $progressImage } = useImageViewerContext(); const progressEvent = useStore($progressEvent); const progressImage = useStore($progressImage); + const [imageToRender, setImageToRender] = useState(null); + + useEffect(() => { + if (!selectedImageName) { + setImageToRender(null); + return; + } + + if (!imageDTO || imageToRender?.image_name === imageDTO.image_name) { + return; + } + + let canceled = false; + + const onReady = () => { + if (canceled) { + return; + } + setImageToRender(imageDTO); + }; + + if (typeof window === 'undefined') { + onReady(); + return; + } + + const preloader = new window.Image(); + + preloader.onload = onReady; + preloader.onerror = onReady; + preloader.src = imageDTO.image_url; + + if (preloader.complete) { + onReady(); + } + + return () => { + canceled = true; + preloader.onload = null; + preloader.onerror = null; + }; + }, [imageDTO, imageToRender?.image_name, selectedImageName]); // Show and hide the next/prev buttons on mouse move const [shouldShowNextPrevButtons, setShouldShowNextPrevButtons] = useState(false); @@ -36,6 +89,50 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu }, 500); }, []); + const handleViewerArrowNavigation = useCallback( + (event: KeyboardEvent, navigate: () => void) => { + if (!navigationApi.isViewerArrowNavigationMode(activeTab) || !imageToRender || isFetching) { + return; + } + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + event.preventDefault(); + navigate(); + }, + [activeTab, imageToRender, isFetching] + ); + + const onHotkeyPrevImage = useCallback( + (event: KeyboardEvent) => { + handleViewerArrowNavigation(event, goToPreviousImage); + }, + [goToPreviousImage, handleViewerArrowNavigation] + ); + + const onHotkeyNextImage = useCallback( + (event: KeyboardEvent) => { + handleViewerArrowNavigation(event, goToNextImage); + }, + [goToNextImage, handleViewerArrowNavigation] + ); + + useRegisteredHotkeys({ + id: 'galleryNavLeft', + category: 'gallery', + callback: onHotkeyPrevImage, + options: { preventDefault: true }, + dependencies: [onHotkeyPrevImage], + }); + + useRegisteredHotkeys({ + id: 'galleryNavRight', + category: 'gallery', + callback: onHotkeyNextImage, + options: { preventDefault: true }, + dependencies: [onHotkeyNextImage], + }); + const withProgress = shouldShowProgressInViewer && progressImage !== null; return ( @@ -48,19 +145,12 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu justifyContent="center" position="relative" > - {imageDTO && ( - - + {imageToRender && ( + + )} - {!imageDTO && } + {!imageToRender && } {withProgress && ( @@ -72,13 +162,13 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu - {shouldShowItemDetails && imageDTO && !withProgress && ( + {shouldShowItemDetails && imageToRender && !withProgress && ( - + )} - {shouldShowNextPrevButtons && imageDTO && ( + {shouldShowNextPrevButtons && imageToRender && ( ) => { + event.preventDefault(); +}; + +const preventButtonFocusOnMouseDown = (event: MouseEvent) => { + event.preventDefault(); +}; + +const blurButtonOnPointerUp = (event: PointerEvent) => { + event.currentTarget.blur(); +}; + const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const lastSelectedItem = useAppSelector(selectLastSelectedItem); - const { imageNames, isFetching } = useGalleryImageNames(); - - const isOnFirstItem = useMemo( - () => (lastSelectedItem ? imageNames.at(0) === lastSelectedItem : false), - [imageNames, lastSelectedItem] - ); - const isOnLastItem = useMemo( - () => (lastSelectedItem ? imageNames.at(-1) === lastSelectedItem : false), - [imageNames, lastSelectedItem] - ); - - const onClickLeftArrow = useCallback(() => { - const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) - 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const n = imageNames.at(clampedIndex); - if (!n) { - return; - } - dispatch(imageSelected(n)); - }, [dispatch, imageNames, lastSelectedItem]); - - const onClickRightArrow = useCallback(() => { - const targetIndex = lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) + 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const n = imageNames.at(clampedIndex); - if (!n) { - return; - } - dispatch(imageSelected(n)); - }, [dispatch, imageNames, lastSelectedItem]); + const { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching } = useNextPrevItemNavigation(); return ( @@ -62,7 +39,10 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt minH={0} w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} - onClick={onClickLeftArrow} + onClick={goToPreviousImage} + onPointerDown={preventButtonFocusOnPointerDown} + onMouseDown={preventButtonFocusOnMouseDown} + onPointerUp={blurButtonOnPointerUp} isDisabled={isFetching} color="base.100" pointerEvents="auto" @@ -82,7 +62,10 @@ const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineSt minH={0} w={`${ARROW_SIZE}px`} h={`${ARROW_SIZE}px`} - onClick={onClickRightArrow} + onClick={goToNextImage} + onPointerDown={preventButtonFocusOnPointerDown} + onMouseDown={preventButtonFocusOnMouseDown} + onPointerUp={blurButtonOnPointerUp} isDisabled={isFetching} color="base.100" pointerEvents="auto" diff --git a/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts new file mode 100644 index 0000000000..066282d255 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/useNextPrevItemNavigation.ts @@ -0,0 +1,47 @@ +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { clamp } from 'es-toolkit/compat'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { useCallback, useMemo } from 'react'; + +import { useGalleryImageNames } from './use-gallery-image-names'; + +export const useNextPrevItemNavigation = () => { + const dispatch = useAppDispatch(); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const { imageNames, isFetching } = useGalleryImageNames(); + + const currentIndex = useMemo( + () => (lastSelectedItem ? imageNames.findIndex((n) => n === lastSelectedItem) : -1), + [imageNames, lastSelectedItem] + ); + const isOnFirstItem = currentIndex === 0; + const isOnLastItem = currentIndex >= 0 && currentIndex === imageNames.length - 1; + + const navigateBy = useCallback( + (delta: number) => { + const maxIndex = imageNames.length - 1; + if (maxIndex < 0) { + return; + } + + const targetIndex = currentIndex >= 0 ? clamp(currentIndex + delta, 0, maxIndex) : 0; + const imageName = imageNames[targetIndex]; + if (!imageName) { + return; + } + dispatch(imageSelected(imageName)); + }, + [currentIndex, dispatch, imageNames] + ); + + const goToPreviousImage = useCallback(() => { + navigateBy(-1); + }, [navigateBy]); + + const goToNextImage = useCallback(() => { + navigateBy(1); + }, [navigateBy]); + + return { goToPreviousImage, goToNextImage, isOnFirstItem, isOnLastItem, isFetching }; +}; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts index edd43dd80d..ce6d4af298 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -60,10 +60,15 @@ export const useInvoke = () => { [enqueueCanvas, enqueueGenerate, enqueueUpscaling, enqueueWorkflows, isReady, tabName] ); - const enqueueBack = useCallback(() => { - enqueue(false); + const focusViewerAfterInvoke = useCallback((tab: typeof tabName) => { + void navigationApi.focusPanel(tab, VIEWER_PANEL_ID, 2000, { + blurActiveElement: tab === 'generate' || tab === 'upscaling', + }); + }, []); + + const focusAfterInvoke = useCallback(() => { if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); + focusViewerAfterInvoke(tabName); } else if (tabName === 'workflows') { // Only switch to viewer if the workflow editor is not currently active const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); @@ -71,24 +76,25 @@ export const useInvoke = () => { navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); } } else if (tabName === 'canvas') { - navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); + void navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); } - }, [enqueue, saveAllImagesToGallery, tabName]); + }, [focusViewerAfterInvoke, saveAllImagesToGallery, tabName]); + + const enqueueAndFocus = useCallback( + (prepend: boolean) => { + enqueue(prepend); + focusAfterInvoke(); + }, + [enqueue, focusAfterInvoke] + ); + + const enqueueBack = useCallback(() => { + enqueueAndFocus(false); + }, [enqueueAndFocus]); const enqueueFront = useCallback(() => { - enqueue(true); - if (tabName === 'generate' || tabName === 'upscaling' || (tabName === 'canvas' && saveAllImagesToGallery)) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); - } else if (tabName === 'workflows') { - // Only switch to viewer if the workflow editor is not currently active - const workspace = navigationApi.getPanel('workflows', WORKSPACE_PANEL_ID); - if (!workspace?.api.isActive) { - navigationApi.focusPanel(tabName, VIEWER_PANEL_ID); - } - } else if (tabName === 'canvas') { - navigationApi.focusPanel(tabName, WORKSPACE_PANEL_ID); - } - }, [enqueue, saveAllImagesToGallery, tabName]); + enqueueAndFocus(true); + }, [enqueueAndFocus]); return { enqueueBack, enqueueFront, isLoading, isDisabled: !isReady, enqueue }; }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index 98866a12f9..a1ae782ab0 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -1,4 +1,5 @@ import { logger } from 'app/logging/logger'; +import { type FocusRegionName, setFocusedRegion } from 'common/hooks/focus'; import { createDeferredPromise, type Deferred } from 'common/util/createDeferredPromise'; import { parseify } from 'common/util/serialize'; import type { GridviewApi, IDockviewPanel, IGridviewPanel } from 'dockview'; @@ -9,6 +10,7 @@ import type { Atom } from 'nanostores'; import { atom } from 'nanostores'; import { + GALLERY_PANEL_ID, LAUNCHPAD_PANEL_ID, LEFT_PANEL_ID, LEFT_PANEL_MIN_SIZE_PX, @@ -21,6 +23,10 @@ import { const log = logger('system'); type PanelType = IGridviewPanel | IDockviewPanel; +type PanelWithFocusRegion = { params?: { focusRegion?: FocusRegionName } }; +type FocusPanelOptions = { + blurActiveElement?: boolean; +}; /** * An object that represents a promise that is waiting for a panel to be registered and ready. @@ -87,6 +93,23 @@ export class NavigationApi { */ _disposablesForTab: Map void>> = new Map(); + _setFocusedRegionFromPanel = (tab: TabName, panel: PanelType | null | undefined): void => { + const focusRegion = (panel as PanelWithFocusRegion | null)?.params?.focusRegion; + if (focusRegion && this._app?.activeTab.get() === tab) { + setFocusedRegion(focusRegion); + } + }; + + _blurActiveElement = (): void => { + if (typeof document === 'undefined') { + return; + } + if (!(document.activeElement instanceof HTMLElement)) { + return; + } + document.activeElement.blur(); + }; + /** * Convenience method to add a dispose function for a specific tab. */ @@ -254,10 +277,12 @@ export class NavigationApi { if (api instanceof DockviewApi) { this._currentActiveDockviewPanel.set(tab, api.activePanel?.id ?? null); this._prevActiveDockviewPanel.set(tab, null); + this._setFocusedRegionFromPanel(tab, api.activePanel); const { dispose } = api.onDidActivePanelChange((panel) => { const previousPanelId = this._currentActiveDockviewPanel.get(tab); this._prevActiveDockviewPanel.set(tab, previousPanelId ?? null); this._currentActiveDockviewPanel.set(tab, panel?.id ?? null); + this._setFocusedRegionFromPanel(tab, panel); }); this._addDisposeForTab(tab, dispose); } @@ -375,7 +400,7 @@ export class NavigationApi { * } * ``` */ - focusPanel = async (tab: TabName, panelId: string, timeout = 2000): Promise => { + focusPanel = async (tab: TabName, panelId: string, timeout = 2000, options?: FocusPanelOptions): Promise => { try { this.switchToTab(tab); await this.waitForPanel(tab, panelId, timeout); @@ -390,6 +415,10 @@ export class NavigationApi { // Dockview uses the term "active", but we use "focused" for consistency. panel.api.setActive(); + if (options?.blurActiveElement) { + this._blurActiveElement(); + } + this._setFocusedRegionFromPanel(tab, panel); log.trace(`Focused panel ${key}`); return true; @@ -715,6 +744,56 @@ export class NavigationApi { .map((key) => key.substring(prefix.length)); }; + /** + * Returns true when a specific dockview panel is the currently active panel for the tab. + */ + isDockviewPanelActive = (tab: TabName, panelId: string): boolean => { + return this._currentActiveDockviewPanel.get(tab) === panelId; + }; + + /** + * Returns true when both side panels are collapsed in the provided tab. + */ + isFullscreen = (tab: TabName): boolean => { + const leftPanel = this.getPanel(tab, LEFT_PANEL_ID); + const rightPanel = this.getPanel(tab, RIGHT_PANEL_ID); + + if (!(leftPanel instanceof GridviewPanel) || !(rightPanel instanceof GridviewPanel)) { + return false; + } + + return leftPanel.width === 0 && rightPanel.width === 0; + }; + + /** + * Returns true when the gallery panel is collapsed in the provided tab. + */ + isGalleryPanelCollapsed = (tab: TabName): boolean => { + const galleryPanel = this.getPanel(tab, GALLERY_PANEL_ID); + if (!(galleryPanel instanceof GridviewPanel)) { + return false; + } + return galleryPanel.height <= (galleryPanel.minimumHeight ?? 0); + }; + + /** + * Returns true when the right panel is collapsed in the provided tab. + */ + isRightPanelCollapsed = (tab: TabName): boolean => { + const rightPanel = this.getPanel(tab, RIGHT_PANEL_ID); + if (!(rightPanel instanceof GridviewPanel)) { + return false; + } + return rightPanel.width === 0; + }; + + /** + * Returns true when viewer-level left/right arrow navigation should be active for gallery browsing. + */ + isViewerArrowNavigationMode = (tab: TabName): boolean => { + return this.isFullscreen(tab) || this.isRightPanelCollapsed(tab) || this.isGalleryPanelCollapsed(tab); + }; + /** * Unregister all panels for a tab. Any pending waiters for these panels will be rejected. * @param tab - The tab to unregister panels for From 863fa505516d5662ff68c5ccae89535bafce9c26 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Mon, 9 Mar 2026 13:56:56 -0400 Subject: [PATCH 21/24] Doc: update multiuser mode documentation (#8953) * docs(multiuser): update multiuser mode documentation * Update docs/multiuser/user_guide.md Co-authored-by: dunkeroni * Update docs/multiuser/user_guide.md Co-authored-by: dunkeroni * Update docs/multiuser/user_guide.md Co-authored-by: dunkeroni * slight wording change * add info about the host interface binding option --------- Co-authored-by: dunkeroni --- docs/assets/multiuser/admin-add-user-1.png | Bin 0 -> 12975 bytes docs/assets/multiuser/admin-add-user-2.png | Bin 0 -> 14788 bytes docs/assets/multiuser/admin-add-user-3.png | Bin 0 -> 18632 bytes docs/assets/multiuser/admin-setup.png | Bin 0 -> 40724 bytes docs/assets/multiuser/user-login-1.png | Bin 0 -> 17252 bytes docs/multiuser/user_guide.md | 330 ++++++++------------- 6 files changed, 120 insertions(+), 210 deletions(-) create mode 100644 docs/assets/multiuser/admin-add-user-1.png create mode 100644 docs/assets/multiuser/admin-add-user-2.png create mode 100644 docs/assets/multiuser/admin-add-user-3.png create mode 100644 docs/assets/multiuser/admin-setup.png create mode 100644 docs/assets/multiuser/user-login-1.png diff --git a/docs/assets/multiuser/admin-add-user-1.png b/docs/assets/multiuser/admin-add-user-1.png new file mode 100644 index 0000000000000000000000000000000000000000..706039d50cbc205d867862ddba0b30282c4f6c58 GIT binary patch literal 12975 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

{W2@H@Tu?}UG!ePp#KZv5L~Cm_;9 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 literal 0 HcmV?d00001 diff --git a/docs/assets/multiuser/user-login-1.png b/docs/assets/multiuser/user-login-1.png new file mode 100644 index 0000000000000000000000000000000000000000..8c4bec22943150152f0886e18d3ce4c720c0d7b9 GIT binary patch literal 17252 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 literal 0 HcmV?d00001 diff --git a/docs/multiuser/user_guide.md b/docs/multiuser/user_guide.md index dd0d791aa6..9c950913de 100644 --- a/docs/multiuser/user_guide.md +++ b/docs/multiuser/user_guide.md @@ -2,74 +2,131 @@ ## Overview -InvokeAI supports both single-user and multi-user modes. In -single-user mode, no login is required and you have access to all -features. In multi-user mode, multiple people can use the same -InvokeAI instance while keeping their work private and organized. +Multi-User mode is a recent feature (introduced in version 6.12), which allows multiple individuals to share a single InvokeAI server while keeping their work separate and organized. Each user has their own username and login password, images, assets, image boards, customization settings and workflows. -### Single-User vs Multi-User Mode +Two types of users are recognized: -**Single-User Mode:** +* A user with **Administrator** status can add, remove and modify other users, and can install models. They also have the ability to view the full session queue and pause or kill other users' jobs. +* **Non-administrator** users can modify their own profile but not others. They also do not have the ability to install or configure models, but must ask an Administrator to do this task. -- No login required - direct access to InvokeAI -- All functionality enabled by default -- All boards and images visible in a unified view -- Ideal for personal use or trusted environments -- Enabled when `multiuser: false` in config or option is absent +Multiple users can be granted Administrator status. -**Multi-User Mode:** - -- Secure login required for access -- User isolation for boards, images, and workflows -- Role-based permissions (Administrator vs Regular User) -- Ideal for shared servers or team environments -- Enabled when `multiuser: true` in config - -!!! note "Mode Switching" - - If you switch from multi-user mode to single-user mode, - all boards and images from different users will be combined - into a single unified view. When switching back to multi-user - mode, they will be separated again by user ownership. +*** ## Getting Started +To activate Multi-User mode, open the `INVOKEAI_ROOT/invokeai.yaml` configuration file in a text editor. Add this line anywhere in the file: +```yaml +multiuser: true +``` + +You may also wish to make InvokeAI available to other machines on your local LAN. Add an additional line to `invokeai.yaml`: + +```yaml +host: 0.0.0.0 +``` + +Restart the server. It will now be in multi-user mode. If you enabled +the `host` option, other users on your home or office LAN will be able +to reach it by browsing to the IP address of the machine the backend +is running on (`http://host-ip-address:9090`). + +!!! tip "Do not expose InvokeAI to the internet" + It is not recommended to expose the InvokeAI host to the internet + due to security concerns. + ### Initial Setup (First Time in Multi-User Mode) If you're the first person to access a fresh InvokeAI installation in multi-user mode, you'll see the **Administrator Setup** dialog: -1. Enter your email address (this will be your username) -2. Create a display name +![Administrator Setup Screen](../../assets/multiuser/admin-setup.png) + +Now + +1. Enter your email address (this will be your login name) +2. Create a display name (this will be the name other users see) 3. Choose a strong password that meets the requirements: - - At least 8 characters long - - Contains uppercase letters - - Contains lowercase letters - - Contains numbers + - At least 8 characters long + - Contains uppercase letters + - Contains lowercase letters + - Contains numbers 4. Confirm your password 5. Click **Create Administrator Account** You'll now be taken to a login screen and can enter the credentials you just created. -### Accessing InvokeAI +### Adding and Modifying Users -**In Single-User Mode:** +If you are logged in as Administrator, you can add additional users. Click on the small "person silhouette" icon at the bottom left of the main Invoke screen and select "User Management:" -1. Navigate to your InvokeAI URL (e.g., `http://localhost:9090`) -2. You'll go directly to the InvokeAI interface -3. No login required - start creating immediately! +![Administrator Menu](../../assets/multiuser/admin-add-user-1.png) -**In Multi-User Mode:** +This will take you to the User Management screen... -1. Navigate to your InvokeAI URL (e.g., `http://localhost:9090`) -2. You'll see the login screen -3. Enter your email address and password provided by your administrator -4. Click **Sign In** +![User Management screen](../../assets/multiuser/admin-add-user-2.png) -!!! tip "Remember Me" - In multi-user mode, check the "Remember me" box to stay logged in for 7 days. Otherwise, your session will expire after 24 hours. +...where you can click "Create User" to add a new user. -## Understanding User Roles (Multi-User Mode Only) +![Add User Screen](../../assets/multiuser/admin-add-user-3.png) + +The User Management screen also allows you to: + +1. Temporarily change a user's status to Inactive, preventing them from logging in to Invoke. +2. Edit a user (by clicking on the pencil icon) to change the user's display name or password. +3. Permanently delete a user. +4. Grant a user Administrator privileges. + +### Command-line User Management Scripts + +Administrators can also use a series of command-line scripts to add, modify, or delete users. If you use the launcher, click the ">" icon to enter the command-line interface. Otherwise, if you are a native command-line user, activate the InvokeAI environment from your terminal. + +The commands are named: + +* **invoke-useradd** -- add a user +* **invoke-usermod** -- modify a user +* **invoke-userdel** -- delete a user +* **invoke-userlist** -- list all users + +Pass the `--help` argument to get the usage of each script. For example: + +```bash +> invoke-useradd --help +usage: invoke-useradd [-h] [--root ROOT] [--email EMAIL] [--password PASSWORD] [--name NAME] [--admin] + +Add a user to the InvokeAI database + +options: + -h, --help show this help message and exit + --root ROOT, -r ROOT Path to the InvokeAI root directory. If omitted, the root is resolved in this order: the $INVOKEAI_ROOT environment + variable, the active virtual environment's parent directory, or $HOME/invokeai. + --email EMAIL, -e EMAIL + User email address + --password PASSWORD, -p PASSWORD + User password + --name NAME, -n NAME User display name (optional) + --admin, -a Make user an administrator + +If no arguments are provided, the script will run in interactive mode. +``` + +*** + +## Logging in as a Non-Administrative User + +If you are a registered user on the system, enter your email address and password to log in. The Administrator will be able to provide you with the values to use: + +![Login Screen](../../assets/multiuser/user-login-1.png) + +As an unprivileged user you can do pretty much anything that's allowed under single-user mode -- generating images, using LoRAs, creating and running workflows, creating image boards -- but you are restricted against installing new models, changing low-level server settings, or interfering with other users. More information on user roles is given below. + +### Changing your Profile + +To change your display name or profile, click on the person silhouette icon at the bottom left of the screen and choose "My Profile". This will take you to a screen that lets you change these values. At this time you can change your display name but not your login ID (ordinarily your contact email address). + +*** + +## Understanding User Roles In single-user mode, you have access to all features without restrictions. In multi-user mode, InvokeAI has two user roles: @@ -80,11 +137,11 @@ As a regular user, you can: - ✅ Create and manage your own image boards - ✅ Generate images using all AI tools (Linear, Canvas, Upscale, Workflows) - ✅ Create, save, and load your own workflows -- ✅ Access workflows marked as public - ✅ View your own generation queue - ✅ Customize your UI preferences (theme, hotkeys, etc.) +- ✅ View available models (read-only access to Model Manager) - ✅ Access shared boards (based on permissions granted to you) (FUTURE FEATURE) -- ✅ **View available models** (read-only access to Model Manager) +- ✅ Access workflows marked as public (FUTURE FEATURE) You cannot: @@ -95,7 +152,6 @@ You cannot: - ❌ View or cancel other users' generation tasks !!! tip "The generation queue" - When two or more users are accessing InvokeAI at the same time, their image generation jobs will be placed on the session queue on a first-come, first-serve basis. This means that you will have to @@ -121,50 +177,32 @@ Administrators have all regular user capabilities, plus: - ✅ Access system configuration - ✅ Grant or revoke admin privileges -## Working with Your Content +*** + +## Working with Your Content in Multi-User Mode ### Image Boards -Image boards help organize your generated images. Each user has their own private boards. +In multi-user model, Image Boards work as before. Each user can create an unlimited number of boards and organize their images and assets as they see fit. Boards are private: you cannot see a board owned by a different user. -**Creating a Board:** +!!! tip "Shared Boards" + InvokeAI 6.13 will add support for creating public boards that are accessible to all users. -1. Click the **+** button in the Boards panel -2. Enter a board name -3. Press Enter or click Create +The Administrator can see all users Image Boards and their contents. -**Managing Boards:** +### Going From Multi-User to Single-User mode -- Click a board to select it -- Generated images will automatically be added to the selected board -- Right-click a board for options (rename, delete, archive) -- Drag images between boards to reorganize - -**Board Visibility:** - -- Your boards are private by default -- Only administrators can create shared boards (FUTURE FEATURE) -- You'll see shared boards you have access to in a separate section +If an InvokeAI instance was in multiuser mode and then restarted in single user mode (by setting `multiuser: false` in the configuration file), all users' boards will be consolidated in one place. Any images that were in "Uncategorized" will be merged together into a single Uncategorized board. If, at a later date, the server is restarted in multi-user mode, the boards and images will be separated and restored to their owners. ### Workflows -Workflows are reusable generation templates that you create in the Workflow Editor. +In the current released version (6.12) workflows are always shared among users. Any workflow that you create will be visible to other users and vice-versa, and there is no protection against one user modifying another user's workflow. -**Creating a Workflow:** +!!! tip "Private and Shared Workflows" + InvokeAI 6.13 will provide the ability to create private and shared workflows. A private workflow can only be viewed by the user who created it. At any time, however, the user can designate the workflow *shared*, in which case it can be opened on a read-only basis by all logged-in users. -1. Go to the **Workflows** tab -2. Build your workflow using nodes -3. Click **Save** and give it a name -4. Your workflow is saved to your personal library -**Workflow Privacy:** - -- Your workflows are private by default -- Only you can see and edit your workflows -- Administrators can mark workflows as "public" for all users to access -- Public workflows appear in everyone's workflow library but remain read-only - -### Your Generation Queue +### The Generation Queue The queue shows your pending and running generation tasks. @@ -183,69 +221,13 @@ The queue shows your pending and running generation tasks. - Administrators can view all queues for troubleshooting - Your generations won't interfere with other users' tasks -## Using Shared Boards (FUTURE FEATURE) - -Shared boards are a feature that will be added in a future -release. Administrators will able to designate certain boards as being -accessible to multiple users, allowing for collaboration among users -while maintaining security. - -### Accessing Shared Boards - -Shared boards appear in your Boards panel marked with a sharing icon. You can: - -- View images on shared boards (if you have read access) -- Add images to shared boards (if you have write access) -- Use shared boards like your personal boards - -### Permission Levels - -Shared boards have three permission levels: - -| Permission | View Images | Add Images | Edit/Delete | Manage Sharing | -|------------|-------------|------------|-------------|----------------| -| **Read** | ✅ | ❌ | ❌ | ❌ | -| **Write** | ✅ | ✅ | ✅ | ❌ | -| **Admin** | ✅ | ✅ | ✅ | ✅ | - -!!! note "Shared boards" - Only administrators will be able to create shared boards and - assign initial permissions. - -## Viewing Models (Read-Only) - -Regular users have read-only access to the Model Manager, allowing you to: - -**What You Can View:** - -- ✅ Browse all available models -- ✅ See model details and configurations -- ✅ View default settings for each model -- ✅ Check model metadata and descriptions -- ✅ See which models are installed - -**What You Cannot Do:** - -- ❌ Install new models -- ❌ Delete or modify existing models -- ❌ Change model configurations -- ❌ Upload or change model images -- ❌ Convert models between formats - -**Accessing the Model Manager:** - -1. Click on the **Models** tab in the navigation -2. Browse available models -3. Click on any model to view its details - -!!! tip "Need a New Model?" - If you need a model that isn't installed, ask your administrator to add it. +*** ## Customizing Your Experience ### Personal Preferences -Your UI preferences are saved to your account: +Your UI preferences are saved to your account and are restored when you log in: - **Theme**: Choose between light and dark modes - **Hotkeys**: Customize keyboard shortcuts @@ -254,38 +236,7 @@ Your UI preferences are saved to your account: These settings are stored per-user and won't affect other users. -### Profile Settings (Multi-User Mode) - -In multi-user mode, access your profile by clicking your name in the top-right corner: - -**Display Name:** Update how your name appears throughout the UI - -**Change Password:** - -!!! info "Password Changes" - A web-based interface for users to change their own passwords is coming in a future release. Until then, contact your administrator to reset your password if needed. - -## Security Best Practices - -### Password Security - -- Use a strong, unique password -- Don't share your password with others -- Change your password regularly -- Use a password manager to store complex passwords - -### Session Security - -- Log out when using a shared computer -- Be aware of your session timeout (24 hours or 7 days with "remember me") -- Your session will automatically expire for security -- You'll need to log in again after the session expires - -### Data Privacy - -- Your boards, images, and workflows are private by default -- Other users cannot access your content unless explicitly shared -- Only administrators can see all users' content for management purposes +*** ## Troubleshooting @@ -372,49 +323,8 @@ In multi-user mode, access your profile by clicking your name in the top-right c - Check if your generation is paused - Contact administrator if stuck for extended period -## Common Tasks -### Changing Your Password - -!!! note This is a FUTURE FEATURE. For now, the Administrator must change/reset a user's password using command-line tools. - -1. Click your display name (top-right corner) -2. Select **Change Password** -3. Enter current password -4. Enter new password (8+ characters, mixed case, numbers) -5. Confirm new password -6. Click **Update Password** - -### Creating a New Board - -1. Navigate to the Gallery or Canvas tab -2. Find the Boards panel (usually on the left) -3. Click the **+ New Board** button -4. Type a descriptive name -5. Press Enter - -### Saving a Workflow - -1. Create or edit a workflow in the Workflows tab -2. Click **Save** in the top bar -3. Enter a workflow name -4. Optionally add a description -5. Click **Save Workflow** - -### Finding a Public Workflow - -!!! note Sharing of workflows is a FUTURE FEATURE, not yet implemented - -1. Go to the **Workflows** tab -2. Open the workflow library -3. Public workflows are marked with a 🌐 icon -4. Click to load and use the workflow - -### Logging Out - -1. Click your display name (top-right corner) -2. Select **Logout** -3. You'll be redirected to the login screen +*** ## Frequently Asked Questions From 2179d93ce02e4680927e4eda3a931c1741bf7e6d Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:01:54 +0800 Subject: [PATCH 22/24] docs: Fix typo in README.md - 'easy' should be 'ease' (#8948) Co-authored-by: Contributor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6631cc33dc..06fc98e46b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The Unified Canvas is a fully integrated canvas implementation with support for ### Workflows & Nodes -Invoke offers a fully featured workflow management solution, enabling users to combine the power of node-based workflows with the easy of a UI. This allows for customizable generation pipelines to be developed and shared by users looking to create specific workflows to support their production use-cases. +Invoke offers a fully featured workflow management solution, enabling users to combine the power of node-based workflows with the ease of a UI. This allows for customizable generation pipelines to be developed and shared by users looking to create specific workflows to support their production use-cases. ### Board & Gallery Management From f01cbd35a8bfe480351008ade1fe277cec77bdce Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:03:32 +0800 Subject: [PATCH 23/24] docs: Fix typo in contributing guide - remove extra 'the' (#8949) Co-authored-by: Contributor --- docs/contributing/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 79c1082746..a582a95128 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -18,7 +18,7 @@ If you’d like to add a Node, please see our [nodes contribution guide](../node Helping support other users in [Discord](https://discord.gg/ZmtBAhwWhy) and on Github are valuable forms of contribution that we greatly appreciate. -We receive many issues and requests for help from users. We're limited in bandwidth relative to our the user base, so providing answers to questions or helping identify causes of issues is very helpful. By doing this, you enable us to spend time on the highest priority work. +We receive many issues and requests for help from users. We're limited in bandwidth relative to our user base, so providing answers to questions or helping identify causes of issues is very helpful. By doing this, you enable us to spend time on the highest priority work. ## Documentation From c8ac303ad2fa77b441d3a3b02423750d4fcc7de9 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 9 Mar 2026 21:16:39 +0100 Subject: [PATCH 24/24] ui: translations update from weblate (#8947) * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2205 of 2250 strings) Co-authored-by: Riccardo Giovanetti Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ Translation: InvokeAI/Web UI * translationBot(ui): update translation files Updated by "Remove blank strings" hook in Weblate. Co-authored-by: Hosted Weblate Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ Translation: InvokeAI/Web UI * translationBot(ui): update translation (Italian) Currently translated at 97.8% (2210 of 2259 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.8% (2224 of 2272 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 98.1% (2252 of 2295 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 98.0% (2264 of 2309 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Russian) Currently translated at 60.7% (1419 of 2334 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/ru/ * translationBot(ui): update translation (Italian) Currently translated at 98.1% (2290 of 2334 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.7% (2319 of 2372 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.7% (2327 of 2380 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.7% (2328 of 2382 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ * translationBot(ui): update translation (Italian) Currently translated at 97.5% (2370 of 2429 strings) Translation: InvokeAI/Web UI Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/ --------- Co-authored-by: Riccardo Giovanetti Co-authored-by: DustyShoe --- invokeai/frontend/web/public/locales/it.json | 51 +++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index c0a6f0473d..d17d36d5c0 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -1015,7 +1015,8 @@ "showDetailedInvocationProgress": "Mostra dettagli avanzamento", "enableHighlightFocusedRegions": "Evidenzia le regioni interessate", "modelDescriptionsDisabled": "Descrizioni dei modelli nei menu a discesa disabilitate", - "modelDescriptionsDisabledDesc": "Le descrizioni dei modelli nei menu a discesa sono state disattivate. Abilitale nelle Impostazioni." + "modelDescriptionsDisabledDesc": "Le descrizioni dei modelli nei menu a discesa sono state disattivate. Abilitale nelle Impostazioni.", + "preferAttentionStyleNumeric": "Preferisci lo stile di attenzione numerico" }, "toast": { "uploadFailed": "Caricamento fallito", @@ -3092,6 +3093,52 @@ }, "userMenu": "Menu utente", "logout": "Esci", - "adminOnlyFeature": "Questa funzionalità è disponibile solo per gli amministratori." + "adminOnlyFeature": "Questa funzionalità è disponibile solo per gli amministratori.", + "profile": { + "menuItem": "Il mio profilo", + "title": "Il mio profilo", + "emailReadOnly": "L'indirizzo email non può essere modificato", + "displayName": "Nome da visualizzare", + "displayNamePlaceholder": "Il tuo nome", + "changePassword": "Cambiare la password", + "currentPassword": "Password attuale", + "currentPasswordPlaceholder": "Password attuale", + "newPassword": "Nuova password", + "newPasswordPlaceholder": "Nuova password", + "confirmPassword": "Conferma nuova password", + "confirmPasswordPlaceholder": "Conferma nuova password", + "passwordsDoNotMatch": "Le password non corrispondono", + "saveSuccess": "Profilo aggiornato con successo", + "saveFailed": "Impossibile salvare il profilo. Riprova." + }, + "userManagement": { + "menuItem": "Gestione utenti", + "title": "Gestione utenti", + "displayName": "Nome da visualizzare", + "displayNamePlaceholder": "Nome da visualizzare", + "newPassword": "Nuova password", + "newPasswordPlaceholder": "Lasciare vuoto per mantenere la password corrente", + "role": "Ruolo", + "status": "Stato", + "actions": "Azioni", + "isAdmin": "Amministratore", + "user": "Utente", + "you": "Tu", + "createUser": "Crea utente", + "editUser": "Modifica utente", + "deleteUser": "Elimina utente", + "deleteConfirm": "Vuoi davvero eliminare \"{{name}}\"? Questa azione non può essere annullata.", + "generatePassword": "Genera una password complessa", + "showPassword": "Mostra password", + "hidePassword": "Nascondi password", + "activate": "Attiva", + "deactivate": "Disattiva", + "saveFailed": "Impossibile salvare l'utente. Riprova.", + "deleteFailed": "Impossibile eliminare l'utente. Riprova.", + "loadFailed": "Impossibile caricare gli utenti.", + "back": "Indietro", + "cannotDeleteSelf": "Non puoi eliminare il tuo account", + "cannotDeactivateSelf": "Non puoi disattivare il tuo account" + } } }