diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py
index 2e7e49c41e..b4c1e86cf3 100644
--- a/invokeai/app/api/routers/auth.py
+++ b/invokeai/app/api/routers/auth.py
@@ -79,6 +79,7 @@ class SetupStatusResponse(BaseModel):
setup_required: bool = Field(description="Whether initial setup is required")
multiuser_enabled: bool = Field(description="Whether multiuser mode is enabled")
+ strict_password_checking: bool = Field(description="Whether strict password requirements are enforced")
@auth_router.get("/status", response_model=SetupStatusResponse)
@@ -92,13 +93,17 @@ async def get_setup_status() -> SetupStatusResponse:
# If multiuser is disabled, setup is never required
if not config.multiuser:
- return SetupStatusResponse(setup_required=False, multiuser_enabled=False)
+ return SetupStatusResponse(
+ setup_required=False, multiuser_enabled=False, strict_password_checking=config.strict_password_checking
+ )
# 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)
+ return SetupStatusResponse(
+ setup_required=setup_required, multiuser_enabled=True, strict_password_checking=config.strict_password_checking
+ )
@auth_router.post("/login", response_model=LoginResponse)
@@ -248,7 +253,7 @@ async def setup_admin(
password=request.password,
is_admin=True,
)
- user = user_service.create_admin(user_data)
+ user = user_service.create_admin(user_data, strict_password_checking=config.strict_password_checking)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@@ -359,6 +364,7 @@ async def create_user(
HTTPException: 400 if email already exists or password is weak
"""
user_service = ApiDependencies.invoker.services.users
+ config = ApiDependencies.invoker.services.configuration
try:
user_data = UserCreateRequest(
email=request.email,
@@ -366,7 +372,7 @@ async def create_user(
password=request.password,
is_admin=request.is_admin,
)
- return user_service.create(user_data)
+ return user_service.create(user_data, strict_password_checking=config.strict_password_checking)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@@ -414,6 +420,7 @@ async def update_user(
HTTPException: 404 if user not found
"""
user_service = ApiDependencies.invoker.services.users
+ config = ApiDependencies.invoker.services.configuration
try:
changes = UserUpdateRequest(
display_name=request.display_name,
@@ -421,7 +428,7 @@ async def update_user(
is_admin=request.is_admin,
is_active=request.is_active,
)
- return user_service.update(user_id, changes)
+ return user_service.update(user_id, changes, strict_password_checking=config.strict_password_checking)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
@@ -483,6 +490,7 @@ async def update_current_user(
HTTPException: 404 if user not found
"""
user_service = ApiDependencies.invoker.services.users
+ config = ApiDependencies.invoker.services.configuration
# Verify current password when attempting a password change
if request.new_password is not None:
@@ -509,6 +517,8 @@ async def update_current_user(
display_name=request.display_name,
password=request.new_password,
)
- return user_service.update(current_user.user_id, changes)
+ return user_service.update(
+ current_user.user_id, changes, strict_password_checking=config.strict_password_checking
+ )
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py
index 9d5b41e7f5..14b18aac7a 100644
--- a/invokeai/app/api/routers/model_manager.py
+++ b/invokeai/app/api/routers/model_manager.py
@@ -193,6 +193,23 @@ async def get_model_records_by_attrs(
return configs[0]
+@model_manager_router.get(
+ "/get_by_hash",
+ operation_id="get_model_records_by_hash",
+ response_model=AnyModelConfig,
+)
+async def get_model_records_by_hash(
+ hash: str = Query(description="The hash of the model"),
+) -> AnyModelConfig:
+ """Gets a model by its hash. This is useful for recalling models that were deleted and reinstalled,
+ as the hash remains stable across reinstallations while the key (UUID) changes."""
+ configs = ApiDependencies.invoker.services.model_manager.store.search_by_hash(hash)
+ if not configs:
+ raise HTTPException(status_code=404, detail="No model found with this hash")
+
+ return configs[0]
+
+
@model_manager_router.get(
"/i/{key}",
operation_id="get_model_record",
diff --git a/invokeai/app/invocations/cogview4_text_encoder.py b/invokeai/app/invocations/cogview4_text_encoder.py
index c6ef1663cf..3b5b1dc73f 100644
--- a/invokeai/app/invocations/cogview4_text_encoder.py
+++ b/invokeai/app/invocations/cogview4_text_encoder.py
@@ -6,6 +6,7 @@ from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField
from invokeai.app.invocations.model import GlmEncoderField
from invokeai.app.invocations.primitives import CogView4ConditioningOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
CogView4ConditioningInfo,
ConditioningFieldData,
@@ -46,10 +47,18 @@ class CogView4TextEncoderInvocation(BaseInvocation):
prompt = [self.prompt]
# TODO(ryand): Add model inputs to the invocation rather than hard-coding.
+ glm_text_encoder_info = context.models.load(self.glm_encoder.text_encoder)
with (
- context.models.load(self.glm_encoder.text_encoder).model_on_device() as (_, glm_text_encoder),
+ glm_text_encoder_info.model_on_device() as (_, glm_text_encoder),
context.models.load(self.glm_encoder.tokenizer).model_on_device() as (_, glm_tokenizer),
):
+ repaired_tensors = glm_text_encoder_info.repair_required_tensors_on_device()
+ device = get_effective_device(glm_text_encoder)
+ if repaired_tensors > 0:
+ context.logger.warning(
+ f"Recovered {repaired_tensors} required GLM tensor(s) onto {device} after a partial device mismatch."
+ )
+
context.util.signal_progress("Running GLM text encoder")
assert isinstance(glm_text_encoder, GlmModel)
assert isinstance(glm_tokenizer, PreTrainedTokenizerFast)
@@ -85,9 +94,7 @@ class CogView4TextEncoderInvocation(BaseInvocation):
device=text_input_ids.device,
)
text_input_ids = torch.cat([pad_ids, text_input_ids], dim=1)
- prompt_embeds = glm_text_encoder(
- text_input_ids.to(glm_text_encoder.device), output_hidden_states=True
- ).hidden_states[-2]
+ prompt_embeds = glm_text_encoder(text_input_ids.to(device), output_hidden_states=True).hidden_states[-2]
assert isinstance(prompt_embeds, torch.Tensor)
return prompt_embeds
diff --git a/invokeai/app/invocations/flux2_klein_text_encoder.py b/invokeai/app/invocations/flux2_klein_text_encoder.py
index 6ca307ebf0..b44e782c8a 100644
--- a/invokeai/app/invocations/flux2_klein_text_encoder.py
+++ b/invokeai/app/invocations/flux2_klein_text_encoder.py
@@ -25,6 +25,7 @@ from invokeai.app.invocations.fields import (
from invokeai.app.invocations.model import Qwen3EncoderField
from invokeai.app.invocations.primitives import FluxConditioningOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
from invokeai.backend.patches.layer_patcher import LayerPatcher
from invokeai.backend.patches.lora_conversions.flux_lora_constants import FLUX_LORA_T5_PREFIX
from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
@@ -100,7 +101,12 @@ class Flux2KleinTextEncoderInvocation(BaseInvocation):
tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer)
(_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
- device = text_encoder.device
+ repaired_tensors = text_encoder_info.repair_required_tensors_on_device()
+ device = get_effective_device(text_encoder)
+ if repaired_tensors > 0:
+ context.logger.warning(
+ f"Recovered {repaired_tensors} required Qwen3 tensor(s) onto {device} after a partial device mismatch."
+ )
# Apply LoRA models
lora_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
diff --git a/invokeai/app/invocations/z_image_text_encoder.py b/invokeai/app/invocations/z_image_text_encoder.py
index 06718c4897..c3405d6dc8 100644
--- a/invokeai/app/invocations/z_image_text_encoder.py
+++ b/invokeai/app/invocations/z_image_text_encoder.py
@@ -16,6 +16,7 @@ from invokeai.app.invocations.fields import (
from invokeai.app.invocations.model import Qwen3EncoderField
from invokeai.app.invocations.primitives import ZImageConditioningOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
+from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
from invokeai.backend.patches.layer_patcher import LayerPatcher
from invokeai.backend.patches.lora_conversions.z_image_lora_constants import Z_IMAGE_LORA_QWEN3_PREFIX
from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
@@ -76,11 +77,17 @@ class ZImageTextEncoderInvocation(BaseInvocation):
tokenizer_info = context.models.load(self.qwen3_encoder.tokenizer)
with ExitStack() as exit_stack:
- (_, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device())
+ (cached_weights, text_encoder) = exit_stack.enter_context(text_encoder_info.model_on_device())
(_, tokenizer) = exit_stack.enter_context(tokenizer_info.model_on_device())
- # Use the device that the text_encoder is actually on
- device = text_encoder.device
+ # Use the device that the text encoder is effectively executing on, and repair any required tensors left on
+ # the CPU by a previous interrupted run.
+ repaired_tensors = text_encoder_info.repair_required_tensors_on_device()
+ device = get_effective_device(text_encoder)
+ if repaired_tensors > 0:
+ context.logger.warning(
+ f"Recovered {repaired_tensors} required Qwen3 tensor(s) onto {device} after a partial device mismatch."
+ )
# Apply LoRA models to the text encoder
lora_dtype = TorchDevice.choose_bfloat16_safe_dtype(device)
@@ -90,6 +97,7 @@ class ZImageTextEncoderInvocation(BaseInvocation):
patches=self._lora_iterator(context),
prefix=Z_IMAGE_LORA_QWEN3_PREFIX,
dtype=lora_dtype,
+ cached_weights=cached_weights,
)
)
diff --git a/invokeai/app/services/auth/password_utils.py b/invokeai/app/services/auth/password_utils.py
index 5e64151634..b960af5f1c 100644
--- a/invokeai/app/services/auth/password_utils.py
+++ b/invokeai/app/services/auth/password_utils.py
@@ -1,6 +1,6 @@
"""Password hashing and validation utilities."""
-from typing import cast
+from typing import Literal, cast
from passlib.context import CryptContext
@@ -84,3 +84,30 @@ def validate_password_strength(password: str) -> tuple[bool, str]:
return False, "Password must contain uppercase, lowercase, and numbers"
return True, ""
+
+
+def get_password_strength(password: str) -> Literal["weak", "moderate", "strong"]:
+ """Determine the strength of a password.
+
+ Strength levels:
+ - weak: less than 8 characters
+ - moderate: 8+ characters but missing at least one of uppercase, lowercase, or digit
+ - strong: 8+ characters with uppercase, lowercase, and digit
+
+ Args:
+ password: The password to evaluate
+
+ Returns:
+ One of "weak", "moderate", or "strong"
+ """
+ if len(password) < 8:
+ return "weak"
+
+ 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 "moderate"
+
+ return "strong"
diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py
index 2cc2aaf273..5d1b1d0d8d 100644
--- a/invokeai/app/services/config/config_default.py
+++ b/invokeai/app/services/config/config_default.py
@@ -111,6 +111,7 @@ class InvokeAIAppConfig(BaseSettings):
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.
+ strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.
"""
_root: Optional[Path] = PrivateAttr(default=None)
@@ -206,6 +207,7 @@ class InvokeAIAppConfig(BaseSettings):
# 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.")
+ strict_password_checking: bool = Field(default=False, description="Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.")
# fmt: on
diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py
index f20a1784be..8503811bcd 100644
--- a/invokeai/app/services/model_install/model_install_default.py
+++ b/invokeai/app/services/model_install/model_install_default.py
@@ -663,10 +663,12 @@ class ModelInstallService(ModelInstallServiceBase):
# directory. However, the path we store in the model record may be either a file within the key directory,
# or the directory itself. So we have to handle both cases.
if model_path.is_file() or model_path.is_symlink():
- # Sanity check - file models should be in their own directory under the models dir. The parent of the
- # file should be the model's directory, not the Invoke models dir!
- assert model_path.parent != self.app_config.models_path
- rmtree(model_path.parent)
+ # Delete the individual model file, not the entire parent directory.
+ # Other unrelated files may exist in the same directory.
+ model_path.unlink()
+ # Clean up the parent directory only if it is now empty
+ if model_path.parent != self.app_config.models_path and not any(model_path.parent.iterdir()):
+ model_path.parent.rmdir()
elif model_path.is_dir():
# Sanity check - folder models should be in their own directory under the models dir. The path should
# not be the Invoke models dir itself!
diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py
index 5ad66c5983..728a0adfa3 100644
--- a/invokeai/app/services/users/users_base.py
+++ b/invokeai/app/services/users/users_base.py
@@ -9,17 +9,19 @@ class UserServiceBase(ABC):
"""High-level service for user management."""
@abstractmethod
- def create(self, user_data: UserCreateRequest) -> UserDTO:
+ def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
"""Create a new user.
Args:
user_data: User creation data
+ strict_password_checking: If True (default), passwords must meet strength requirements.
+ If False, any non-empty password is accepted.
Returns:
The created user
Raises:
- ValueError: If email already exists or password is weak
+ ValueError: If email already exists or (when strict) password is weak
"""
pass
@@ -48,18 +50,20 @@ class UserServiceBase(ABC):
pass
@abstractmethod
- def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO:
+ def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
"""Update user.
Args:
user_id: The user ID
changes: Fields to update
+ strict_password_checking: If True (default), passwords must meet strength requirements.
+ If False, any non-empty password is accepted.
Returns:
The updated user
Raises:
- ValueError: If user not found or password is weak
+ ValueError: If user not found or (when strict) password is weak
"""
pass
@@ -98,17 +102,19 @@ class UserServiceBase(ABC):
pass
@abstractmethod
- def create_admin(self, user_data: UserCreateRequest) -> UserDTO:
+ def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
"""Create an admin user (for initial setup).
Args:
user_data: User creation data
+ strict_password_checking: If True (default), passwords must meet strength requirements.
+ If False, any non-empty password is accepted.
Returns:
The created admin user
Raises:
- ValueError: If admin already exists or password is weak
+ ValueError: If admin already exists or (when strict) password is weak
"""
pass
diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py
index 506ae937f0..709e4cb82c 100644
--- a/invokeai/app/services/users/users_default.py
+++ b/invokeai/app/services/users/users_default.py
@@ -21,12 +21,15 @@ class UserService(UserServiceBase):
"""
self._db = db
- def create(self, user_data: UserCreateRequest) -> UserDTO:
+ def create(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> 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)
+ if strict_password_checking:
+ is_valid, error_msg = validate_password_strength(user_data.password)
+ if not is_valid:
+ raise ValueError(error_msg)
+ elif not user_data.password:
+ raise ValueError("Password cannot be empty")
# Check if email already exists
if self.get_by_email(user_data.email) is not None:
@@ -106,7 +109,7 @@ class UserService(UserServiceBase):
last_login_at=datetime.fromisoformat(row[7]) if row[7] else None,
)
- def update(self, user_id: str, changes: UserUpdateRequest) -> UserDTO:
+ def update(self, user_id: str, changes: UserUpdateRequest, strict_password_checking: bool = True) -> UserDTO:
"""Update user."""
# Check if user exists
user = self.get(user_id)
@@ -115,9 +118,12 @@ class UserService(UserServiceBase):
# 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)
+ if strict_password_checking:
+ is_valid, error_msg = validate_password_strength(changes.password)
+ if not is_valid:
+ raise ValueError(error_msg)
+ elif not changes.password:
+ raise ValueError("Password cannot be empty")
# Build update query dynamically based on provided fields
updates: list[str] = []
@@ -208,7 +214,7 @@ class UserService(UserServiceBase):
count = row[0] if row else 0
return bool(count > 0)
- def create_admin(self, user_data: UserCreateRequest) -> UserDTO:
+ def create_admin(self, user_data: UserCreateRequest, strict_password_checking: bool = True) -> UserDTO:
"""Create an admin user (for initial setup)."""
if self.has_admin():
raise ValueError("Admin user already exists")
@@ -220,7 +226,7 @@ class UserService(UserServiceBase):
password=user_data.password,
is_admin=True,
)
- return self.create(admin_data)
+ return self.create(admin_data, strict_password_checking=strict_password_checking)
def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
"""List all users."""
diff --git a/invokeai/backend/model_manager/load/load_base.py b/invokeai/backend/model_manager/load/load_base.py
index a4004afba7..b972969a68 100644
--- a/invokeai/backend/model_manager/load/load_base.py
+++ b/invokeai/backend/model_manager/load/load_base.py
@@ -14,6 +14,9 @@ import torch
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.load.model_cache.cache_record import CacheRecord
+from invokeai.backend.model_manager.load.model_cache.cached_model.cached_model_with_partial_load import (
+ CachedModelWithPartialLoad,
+)
from invokeai.backend.model_manager.load.model_cache.model_cache import ModelCache
from invokeai.backend.model_manager.taxonomy import AnyModel, SubModelType
@@ -80,6 +83,13 @@ class LoadedModelWithoutConfig:
"""Return the model without locking it."""
return self._cache_record.cached_model.model
+ def repair_required_tensors_on_device(self) -> int:
+ """Repair required tensors that should be resident on the cached model's execution device."""
+ cached_model = self._cache_record.cached_model
+ if not isinstance(cached_model, CachedModelWithPartialLoad):
+ return 0
+ return cached_model.repair_required_tensors_on_compute_device()
+
class LoadedModel(LoadedModelWithoutConfig):
"""Context manager object that mediates transfer from RAM<->VRAM."""
diff --git a/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py b/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py
index f80b017ba7..328978b45b 100644
--- a/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py
+++ b/invokeai/backend/model_manager/load/model_cache/cached_model/cached_model_with_partial_load.py
@@ -149,6 +149,27 @@ class CachedModelWithPartialLoad:
"""Unload all weights from VRAM."""
return self.partial_unload_from_vram(self.total_bytes())
+ @torch.no_grad()
+ def repair_required_tensors_on_compute_device(self) -> int:
+ """Repair required non-autocast tensors that were left off the compute device.
+
+ This can happen if an interrupted run leaves the model in a partially inconsistent state. Any repaired device
+ movement invalidates the cached VRAM accounting.
+ """
+ cur_state_dict = self._model.state_dict()
+ keys_to_repair = {
+ key
+ for key in self._keys_in_modules_that_do_not_support_autocast
+ if cur_state_dict[key].device.type != self._compute_device.type
+ }
+ if len(keys_to_repair) == 0:
+ return 0
+
+ self._load_state_dict_with_device_conversion(cur_state_dict, keys_to_repair, self._compute_device)
+ self._move_non_persistent_buffers_to_device(self._compute_device)
+ self._cur_vram_bytes = None
+ return len(keys_to_repair)
+
def _load_state_dict_with_device_conversion(
self, state_dict: dict[str, torch.Tensor], keys_to_convert: set[str], target_device: torch.device
):
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 120eec8062..3891f4874e 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -46,7 +46,8 @@
"passwordsDoNotMatch": "Passwords do not match",
"createAccount": "Create Administrator Account",
"creatingAccount": "Setting up...",
- "setupFailed": "Setup failed. Please try again."
+ "setupFailed": "Setup failed. Please try again.",
+ "passwordHelperRelaxed": "Enter any password (strength will be shown)"
},
"userMenu": "User Menu",
"admin": "Admin",
@@ -102,6 +103,11 @@
"back": "Back",
"cannotDeleteSelf": "You cannot delete your own account",
"cannotDeactivateSelf": "You cannot deactivate your own account"
+ },
+ "passwordStrength": {
+ "weak": "Weak password",
+ "moderate": "Moderate password",
+ "strong": "Strong password"
}
},
"boards": {
diff --git a/invokeai/frontend/web/public/locales/fi.json b/invokeai/frontend/web/public/locales/fi.json
index f03c6f1aa1..54e5a66660 100644
--- a/invokeai/frontend/web/public/locales/fi.json
+++ b/invokeai/frontend/web/public/locales/fi.json
@@ -4,7 +4,8 @@
"uploadImage": "Lataa kuva",
"invokeProgressBar": "Invoken edistymispalkki",
"nextImage": "Seuraava kuva",
- "previousImage": "Edellinen kuva"
+ "previousImage": "Edellinen kuva",
+ "uploadImages": "Lähetä Kuva(t)"
},
"common": {
"languagePickerLabel": "Kielen valinta",
@@ -29,5 +30,28 @@
"galleryImageSize": "Kuvan koko",
"gallerySettings": "Gallerian asetukset",
"autoSwitchNewImages": "Vaihda uusiin kuviin automaattisesti"
+ },
+ "modelManager": {
+ "t5Encoder": "T5-kooderi",
+ "qwen3Encoder": "Qwen3-kooderi",
+ "zImageVae": "VAE (valinnainen)",
+ "zImageQwen3Encoder": "Qwen3-kooderi (valinnainen)",
+ "zImageQwen3SourcePlaceholder": "Pakollinen, jos VAE/Enkooderi on tyhjä",
+ "flux2KleinVae": "VAE (valinnainen)",
+ "flux2KleinQwen3Encoder": "Qwen3-kooderi (valinnainen)"
+ },
+ "auth": {
+ "login": {
+ "title": "Kirjaudu sisään InvokeAI:hin",
+ "password": "Salasana",
+ "passwordPlaceholder": "Salasana",
+ "signIn": "Kirjaudu sisään",
+ "signingIn": "Kirjaudutaan sisään...",
+ "loginFailed": "Kirjautuminen epäonnistui. Tarkista käyttäjätunnuksesi tiedot."
+ },
+ "setup": {
+ "title": "Tervetuloa InvokeAI:hin",
+ "subtitle": "Määritä ensimmäiseksi järjestelmänvalvojan tili"
+ }
}
}
diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json
index d17d36d5c0..7a6dafe4c7 100644
--- a/invokeai/frontend/web/public/locales/it.json
+++ b/invokeai/frontend/web/public/locales/it.json
@@ -3139,6 +3139,11 @@
"back": "Indietro",
"cannotDeleteSelf": "Non puoi eliminare il tuo account",
"cannotDeactivateSelf": "Non puoi disattivare il tuo account"
+ },
+ "passwordStrength": {
+ "weak": "Password debole",
+ "moderate": "Password moderata",
+ "strong": "Password forte"
}
}
}
diff --git a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx
index 9827a4d976..b0ad9a5e04 100644
--- a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx
+++ b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx
@@ -15,34 +15,13 @@ import {
Text,
VStack,
} from '@invoke-ai/ui-library';
+import { validatePasswordField } from 'features/auth/util/passwordUtils';
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();
@@ -60,7 +39,8 @@ export const AdministratorSetup = memo(() => {
}
}, [setupStatus, isLoadingSetup, navigate]);
- const passwordValidation = validatePasswordStrength(password, t);
+ const strictPasswordChecking = setupStatus?.strict_password_checking ?? true;
+ const passwordValidation = validatePasswordField(password, t, strictPasswordChecking, false);
const passwordsMatch = password === confirmPassword;
const handleSubmit = useCallback(
@@ -120,6 +100,13 @@ export const AdministratorSetup = memo(() => {
);
}
+ const passwordStrengthColor =
+ passwordValidation.strength === 'weak'
+ ? 'error.300'
+ : passwordValidation.strength === 'moderate'
+ ? 'warning.300'
+ : 'invokeBlue.300';
+
return (
@@ -192,7 +179,16 @@ export const AdministratorSetup = memo(() => {
{password.length > 0 && !passwordValidation.isValid && (
{passwordValidation.message}
)}
- {password.length === 0 && {t('auth.setup.passwordHelper')}}
+ {password.length > 0 && passwordValidation.isValid && passwordValidation.message && (
+
+ {passwordValidation.message}
+
+ )}
+ {password.length === 0 && (
+
+ {strictPasswordChecking ? t('auth.setup.passwordHelper') : t('auth.setup.passwordHelperRelaxed')}
+
+ )}
diff --git a/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx b/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx
index 4dd88ca1e5..8d587e7249 100644
--- a/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx
+++ b/invokeai/frontend/web/src/features/auth/components/UserManagement.tsx
@@ -37,6 +37,7 @@ import {
} from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectCurrentUser } from 'features/auth/store/authSlice';
+import { validatePasswordField } from 'features/auth/util/passwordUtils';
import type { ChangeEvent, FormEvent } from 'react';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -54,30 +55,12 @@ import type { UserDTO } from 'services/api/endpoints/auth';
import {
useCreateUserMutation,
useDeleteUserMutation,
+ useGetSetupStatusQuery,
useLazyGeneratePasswordQuery,
useListUsersQuery,
useUpdateUserMutation,
} 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 FORM_GRID_COLUMNS = '120px 1fr';
// ---------------------------------------------------------------------------
@@ -105,9 +88,12 @@ const UserFormModal = memo(({ isOpen, onClose, editUser }: UserFormModalProps) =
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
const [triggerGeneratePassword] = useLazyGeneratePasswordQuery();
+ const { data: setupStatus } = useGetSetupStatusQuery();
const isLoading = isCreating || isUpdating;
- const passwordValidation = validatePasswordStrength(password, t);
+ const strictPasswordChecking = setupStatus?.strict_password_checking ?? true;
+ // In edit mode, empty password means "no change" (allowEmpty=true); in create mode password is required (allowEmpty=false)
+ const passwordValidation = validatePasswordField(password, t, strictPasswordChecking, isEdit);
const handleGeneratePassword = useCallback(async () => {
try {
@@ -300,6 +286,21 @@ const UserFormModal = memo(({ isOpen, onClose, editUser }: UserFormModalProps) =
{password.length > 0 && !passwordValidation.isValid && (
{passwordValidation.message}
)}
+ {password.length > 0 && passwordValidation.isValid && passwordValidation.message && (
+
+ {passwordValidation.message}
+
+ )}
diff --git a/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx
index 4504698f0e..02d25b6de9 100644
--- a/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx
+++ b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx
@@ -21,31 +21,17 @@ import {
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectAuthToken, selectCurrentUser, setCredentials } from 'features/auth/store/authSlice';
+import { validatePasswordField } from 'features/auth/util/passwordUtils';
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: '' };
-};
+import {
+ useGetSetupStatusQuery,
+ useLazyGeneratePasswordQuery,
+ useUpdateCurrentUserMutation,
+} from 'services/api/endpoints/auth';
const PASSWORD_GRID_COLUMNS = '180px 1fr';
@@ -67,8 +53,10 @@ export const UserProfile = memo(() => {
const [updateCurrentUser, { isLoading }] = useUpdateCurrentUserMutation();
const [triggerGeneratePassword] = useLazyGeneratePasswordQuery();
+ const { data: setupStatus } = useGetSetupStatusQuery();
- const newPasswordValidation = validatePasswordStrength(newPassword, t);
+ const strictPasswordChecking = setupStatus?.strict_password_checking ?? true;
+ const newPasswordValidation = validatePasswordField(newPassword, t, strictPasswordChecking, true);
const isPasswordChangeAttempted = newPassword.length > 0 || currentPassword.length > 0;
const passwordsMatch = newPassword.length > 0 && newPassword === confirmPassword;
@@ -305,6 +293,21 @@ export const UserProfile = memo(() => {
{newPassword.length > 0 && !newPasswordValidation.isValid && (
{newPasswordValidation.message}
)}
+ {newPassword.length > 0 && newPasswordValidation.isValid && newPasswordValidation.message && (
+
+ {newPasswordValidation.message}
+
+ )}
diff --git a/invokeai/frontend/web/src/features/auth/util/passwordUtils.ts b/invokeai/frontend/web/src/features/auth/util/passwordUtils.ts
new file mode 100644
index 0000000000..53200d2c65
--- /dev/null
+++ b/invokeai/frontend/web/src/features/auth/util/passwordUtils.ts
@@ -0,0 +1,70 @@
+export type PasswordStrength = 'weak' | 'moderate' | 'strong';
+
+export type PasswordValidationResult = {
+ isValid: boolean;
+ message: string;
+ strength: PasswordStrength | null;
+};
+
+/**
+ * Returns the strength level of a password.
+ * - weak: less than 8 characters
+ * - moderate: 8+ characters but missing uppercase, lowercase, or digit
+ * - strong: 8+ characters with uppercase, lowercase, and digit
+ */
+export const getPasswordStrength = (password: string): PasswordStrength => {
+ if (password.length < 8) {
+ return 'weak';
+ }
+ const hasUpper = /[A-Z]/.test(password);
+ const hasLower = /[a-z]/.test(password);
+ const hasDigit = /\d/.test(password);
+ if (!hasUpper || !hasLower || !hasDigit) {
+ return 'moderate';
+ }
+ return 'strong';
+};
+
+/**
+ * Validates a password field.
+ *
+ * In strict mode, passwords must be 8+ characters with uppercase, lowercase, and digits.
+ * In non-strict mode, any non-empty password is accepted but strength is reported.
+ *
+ * @param password - The password to validate
+ * @param t - Translation function
+ * @param strictPasswordChecking - Whether to enforce strict requirements
+ * @param allowEmpty - When true, an empty string is treated as "no change" (valid with no message)
+ */
+export const validatePasswordField = (
+ password: string,
+ t: (key: string) => string,
+ strictPasswordChecking: boolean,
+ allowEmpty = false
+): PasswordValidationResult => {
+ if (password.length === 0) {
+ return { isValid: allowEmpty, message: '', strength: null };
+ }
+
+ const strength = getPasswordStrength(password);
+
+ if (!strictPasswordChecking) {
+ return {
+ isValid: true,
+ message: t(`auth.passwordStrength.${strength}`),
+ strength,
+ };
+ }
+
+ // Strict mode
+ if (password.length < 8) {
+ return { isValid: false, message: t('auth.setup.passwordTooShort'), strength };
+ }
+ 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'), strength };
+ }
+ return { isValid: true, message: '', strength };
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx
index c321317a34..19b0278353 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx
@@ -9,7 +9,8 @@ import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLaye
import type { CanvasEntityIdentifier, CompositeOperation } from 'features/controlLayers/store/types';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
-import { CgPathBack, CgPathCrop, CgPathExclude, CgPathFront, CgPathIntersect } from 'react-icons/cg';
+import { CgPathBack, CgPathExclude, CgPathFront, CgPathIntersect } from 'react-icons/cg';
+import { PiIntersectSquareBold } from 'react-icons/pi';
export const RasterLayerMenuItemsBooleanSubMenu = memo(() => {
const { t } = useTranslation();
@@ -48,7 +49,7 @@ export const RasterLayerMenuItemsBooleanSubMenu = memo(() => {
const disabled = isBusy || !entityIdentifierBelowThisOne;
return (
- }>
+ }>