Procházet zdrojové kódy

Merge remote-tracking branch 'upstream/dev' into feature/upload-prefer-filename-for-name

Sebastian Keet před 1 měsícem
rodič
revize
492d7f27c7
67 změnil soubory, kde provedl 4590 přidání a 919 odebrání
  1. 61 0
      .env.example
  2. 4 0
      CHANGELOG.md
  3. 12 15
      CONTRIBUTING.md
  4. 1 1
      README.md
  5. 6 1
      backend/app/api/routes/auth.py
  6. 18 0
      backend/app/api/routes/kprofiles.py
  7. 16 0
      backend/app/api/routes/mfa.py
  8. 19 0
      backend/app/core/config.py
  9. 8 0
      backend/app/core/database.py
  10. 14 1
      backend/app/core/logging_filters.py
  11. 278 0
      backend/app/core/oidc_env.py
  12. 8 0
      backend/app/main.py
  13. 4 0
      backend/app/models/oidc_provider.py
  14. 3 0
      backend/app/schemas/auth.py
  15. 8 0
      backend/app/schemas/printer.py
  16. 227 115
      backend/app/services/bambu_mqtt.py
  17. 49 0
      backend/app/utils/printer_models.py
  18. 29 0
      backend/tests/integration/test_local_login_gate.py
  19. 801 0
      backend/tests/integration/test_oidc_env_apply.py
  20. 137 0
      backend/tests/integration/test_oidc_env_lock.py
  21. 43 0
      backend/tests/integration/test_oidc_env_startup.py
  22. 18 0
      backend/tests/integration/test_updates_api.py
  23. 21 0
      backend/tests/unit/services/test_bambu_cloud.py
  24. 298 0
      backend/tests/unit/services/test_bambu_mqtt.py
  25. 38 0
      backend/tests/unit/test_log_credential_redaction.py
  26. 126 0
      backend/tests/unit/test_oidc_env_managed_migration.py
  27. 12 0
      backend/tests/unit/test_oidc_env_provider.py
  28. 248 0
      backend/tests/unit/test_oidc_env_reader.py
  29. 10 1
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  30. 37 0
      backend/tests/unit/test_printer_models.py
  31. 33 10
      backend/tests/unit/test_printer_offline_notification.py
  32. 1 485
      frontend/package-lock.json
  33. 91 0
      frontend/src/__tests__/components/OIDCProviderSettings.test.tsx
  34. 54 0
      frontend/src/__tests__/hooks/useCancellableTimeout.test.ts
  35. 0 35
      frontend/src/__tests__/i18n/locales.test.ts
  36. 288 1
      frontend/src/__tests__/pages/QueuePage.test.tsx
  37. 148 1
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  38. 17 0
      frontend/src/__tests__/utils/date.test.ts
  39. 346 0
      frontend/src/__tests__/utils/filamentPresets.test.ts
  40. 9 0
      frontend/src/api/client.ts
  41. 6 2
      frontend/src/components/ConfigureAmsSlotModal.tsx
  42. 272 116
      frontend/src/components/KProfilesView.tsx
  43. 58 39
      frontend/src/components/OIDCProviderSettings.tsx
  44. 37 0
      frontend/src/hooks/useCancellableTimeout.ts
  45. 12 1
      frontend/src/i18n/locales/de.ts
  46. 12 1
      frontend/src/i18n/locales/en.ts
  47. 12 1
      frontend/src/i18n/locales/es.ts
  48. 12 1
      frontend/src/i18n/locales/fr.ts
  49. 12 1
      frontend/src/i18n/locales/it.ts
  50. 12 1
      frontend/src/i18n/locales/ja.ts
  51. 12 1
      frontend/src/i18n/locales/ko.ts
  52. 12 1
      frontend/src/i18n/locales/pt-BR.ts
  53. 12 1
      frontend/src/i18n/locales/ru.ts
  54. 12 1
      frontend/src/i18n/locales/tr.ts
  55. 12 1
      frontend/src/i18n/locales/uk.ts
  56. 12 1
      frontend/src/i18n/locales/zh-CN.ts
  57. 12 1
      frontend/src/i18n/locales/zh-TW.ts
  58. 142 0
      frontend/src/pages/QueuePage.tsx
  59. 120 78
      frontend/src/pages/SettingsPage.tsx
  60. 8 2
      frontend/src/utils/date.ts
  61. 247 0
      frontend/src/utils/filamentPresets.ts
  62. 1 0
      static/assets/index-C_6BSgrK.css
  63. 0 0
      static/assets/index-DPZgvI9N.js
  64. 0 1
      static/assets/index-oReXTzKG.css
  65. 2 2
      static/index.html
  66. 0 0
      test_pipeline_archive_source.3mf
  67. 0 0
      test_pipeline_run_1.3mf

+ 61 - 0
.env.example

@@ -66,3 +66,64 @@ LOG_TO_FILE=true
 # LDAP is governed by its own ldap_enabled toggle and is not affected.
 # Leave unset for normal operation.
 # BAMBUDDY_LOCAL_LOGIN=true
+
+# --- OIDC provider from the environment (#2593) ------------------------------
+# Defines ONE OIDC provider declaratively, for deployments that are managed by
+# compose files or GitOps and never touch the settings UI. Providers created in
+# the UI are unaffected and keep working alongside this one.
+#
+# Activates only when all four required vars below are set; an empty value
+# counts as unset. The provider is written on startup and re-applied on every
+# boot, so the UI shows it as read-only and the API refuses to change it -- an
+# edit there would be reverted at the next restart anyway.
+#
+# Removing the vars DISABLES the provider rather than deleting it: accounts
+# linked to it would otherwise lose their link permanently. Re-adding the vars
+# enables it again with those links intact.
+#
+# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
+#
+# Required:
+# BAMBUDDY_OIDC_NAME=Keycloak
+# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
+# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
+# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
+#
+# Optional, shown with their defaults:
+# BAMBUDDY_OIDC_SCOPES=openid email profile
+# BAMBUDDY_OIDC_ENABLED=true
+# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
+# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
+# BAMBUDDY_OIDC_EMAIL_CLAIM=email
+# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
+# BAMBUDDY_OIDC_ICON_URL=
+# BAMBUDDY_OIDC_AUTOLOGIN=false
+# BAMBUDDY_OIDC_DEFAULT_GROUP=
+#
+# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
+# uses the default; any other value is rejected and the provider is skipped.
+#
+# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
+# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
+# -- group ids are assigned per install, so the same compose file would point at
+# a different group on every deployment. A name that matches no group is
+# refused: the provider is left as it was and the reason is logged, rather than
+# quietly creating under-privileged users the locked UI could not correct. On a
+# FIRST boot that means no provider is created at all and no SSO button appears
+# -- create the group first. Removing the variable clears the group again.
+#
+# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
+# the same email address. With EMAIL_CLAIM=email it is refused unless
+# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
+# verify addresses would let anyone claim someone else's account. The whole
+# config is then skipped and logged; the app still starts.
+#
+# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
+# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
+# URL like http://keycloak:8080 is refused with a single log line and no SSO
+# button; use the externally-reachable HTTPS issuer URL instead.
+#
+# NAME is matched against the existing providers on every boot: setting it to
+# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
+# issuer, client id and secret are replaced and it becomes read-only). Pick a
+# name that doesn't collide unless that takeover is intended.

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 4 - 0
CHANGELOG.md


+ 12 - 15
CONTRIBUTING.md

@@ -223,21 +223,18 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
 
 ### Locale Files
 
-Translations live in `frontend/src/i18n/locales/`:
-
-| File | Language |
-|------|----------|
-| `en.ts` | English (primary) |
-| `de.ts` | German |
-| `fr.ts` | French |
-| `ja.ts` | Japanese |
-| `pt-BR.ts` | Brazilian Portuguese |
-[...]
-check for possibly more files!!!
+Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
+
+To see the current set of locales and check your work:
+
+```bash
+cd frontend
+npm run check:i18n
+```
 
 ### Adding New Strings
 
-1. Add the key to the appropriate section in **all three** locale files
+1. Add the key to the appropriate section in **every** locale file
 2. Use the `useTranslation` hook in your component:
 
 ```tsx
@@ -253,9 +250,9 @@ function MyComponent() {
 
 ### Important Notes
 
-- All three locale files must use the **same key structure** — same nesting, same key paths
-- Always add keys to all three locales to maintain parity
-- Run frontend tests after changes — locale parity is validated
+- Every locale file must use the **same key structure** — same nesting, same key paths
+- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
+- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
 - If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
 
 ## Authentication & Permissions

+ 1 - 1
README.md

@@ -6,7 +6,7 @@
 
 <p align="center">
   <strong>Your printers. No cloud. Your rules.</strong><br>
-  Self-hosted command center for Bambu Lab &mdash; from one A1 to a 40-printer farm.
+  Self-hosted command center for Bambu Lab &mdash; from one A1 to an entire print farm.
 </p>
 
 <p align="center">

+ 6 - 1
backend/app/api/routes/auth.py

@@ -35,6 +35,7 @@ from backend.app.core.auth import (
     security,
 )
 from backend.app.core.database import async_session, get_db
+from backend.app.core.oidc_env import env_bool
 from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
@@ -122,7 +123,11 @@ def _local_login_env_bypass() -> bool:
     an install whose SSO provider is unreachable. Accepted truthy values:
     ``true``, ``1``, ``yes`` (case-insensitive).
     """
-    return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
+    # strict=False: this runs on the login/forgot-password request path, not at
+    # startup. An unrecognized value must fall back to "off" (the safe default),
+    # never raise -- a 500 on the recovery endpoint is the opposite of what this
+    # bypass is for.
+    return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
 
 
 def _get_client_ip(request: Request) -> str:

+ 18 - 0
backend/app/api/routes/kprofiles.py

@@ -148,6 +148,9 @@ async def set_kprofile(
         )
         if not delete_success:
             raise HTTPException(500, "Failed to delete existing K-profile for edit")
+        ok, detail = await client.await_cali_ack(delete_success)
+        if not ok:
+            raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
 
         # Wait for printer to process the delete before adding
         await asyncio.sleep(0.5)
@@ -179,6 +182,13 @@ async def set_kprofile(
     if not success:
         raise HTTPException(500, "Failed to send K-profile command")
 
+    # The printer answers extrusion_cali_set with result/reason, echoing our
+    # sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
+    # so a rejected write was reported to the user as saved.
+    ok, detail = await client.await_cali_ack(success)
+    if not ok:
+        raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
+
     message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
     return {"success": True, "message": message}
 
@@ -239,6 +249,10 @@ async def set_kprofiles_batch(
     if not success:
         raise HTTPException(500, "Failed to send K-profiles batch command")
 
+    ok, detail = await client.await_cali_ack(success)
+    if not ok:
+        raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
+
     return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
 
 
@@ -283,6 +297,10 @@ async def delete_kprofile(
     if not success:
         raise HTTPException(500, "Failed to send K-profile delete command")
 
+    ok, detail = await client.await_cali_ack(success)
+    if not ok:
+        raise HTTPException(500, f"Printer rejected the delete: {detail}")
+
     # Wait for printer to process the delete before frontend refetches
     await asyncio.sleep(0.5)
 

+ 16 - 0
backend/app/api/routes/mfa.py

@@ -1404,6 +1404,18 @@ async def create_oidc_provider(
     return _build_provider_response(provider)
 
 
+def _refuse_if_env_managed(provider: OIDCProvider) -> None:
+    """Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
+    edit here would be accepted and then silently reverted at the next restart.
+    BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
+    unusable, so refusing outright cannot lock anyone out."""
+    if provider.is_env_managed:
+        raise HTTPException(
+            status_code=status.HTTP_409_CONFLICT,
+            detail="This OIDC provider is managed by environment variables and cannot be modified.",
+        )
+
+
 @router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
 async def update_oidc_provider(
     provider_id: int,
@@ -1426,6 +1438,7 @@ async def update_oidc_provider(
     provider = result2.scalar_one_or_none()
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     if body.default_group_id is not None:
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
@@ -1503,6 +1516,7 @@ async def delete_oidc_provider(
     provider = result2.scalar_one_or_none()
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     await db.delete(provider)
     await db.commit()
@@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     # Setting deferred columns is safe — no read happens, just a write.
     provider.icon_url = None
@@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
     if not provider.icon_url:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,

+ 19 - 0
backend/app/core/config.py

@@ -135,6 +135,25 @@ _INTENTIONAL_UNSETTINGS = {
     "LOG_DIR",  # config.py (above)
     "LOG_LEVEL",  # main.py logging setup
     "BUG_REPORT_RELAY_URL",  # config.py (above)
+    # #1589 — api/routes/auth.py reads this on the login path. Unregistered it
+    # logged "possible typo" at every boot, telling an operator who is locked
+    # out and following the documented recovery that the variable is not real.
+    "BAMBUDDY_LOCAL_LOGIN",
+    # #2593 — core/oidc_env.py reads these directly; they are not Settings
+    # fields because they map to an OIDCProvider row, not to app config.
+    "BAMBUDDY_OIDC_NAME",
+    "BAMBUDDY_OIDC_ISSUER_URL",
+    "BAMBUDDY_OIDC_CLIENT_ID",
+    "BAMBUDDY_OIDC_CLIENT_SECRET",
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
 }
 
 _known_settings_fields = {f.upper() for f in settings.model_fields}

+ 8 - 0
backend/app/core/database.py

@@ -3808,6 +3808,14 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
 
+    # Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
+    # provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
+    # rejects ``DEFAULT 0`` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
+
     # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
     # the start-watchdog reverted the row from 'printing' back to 'pending' so a
     # printer that never actually starts stops being retried forever. INTEGER

+ 14 - 1
backend/app/core/logging_filters.py

@@ -27,7 +27,20 @@ import re
 # external camera URL) from leaving its tail in the log. Named groups let
 # callers choose how much to mask: the log pipeline keeps the username, the
 # support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
-URL_CREDENTIALS_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@")
+#
+# The scheme's repetition is bounded deliberately. As an unbounded ``*`` the
+# match was quadratic in the length of the subject (CodeQL py/polynomial-redos):
+# on a long run of scheme-legal characters the engine restarts at every offset
+# and consumes to the end each time before failing to find ``://``. Measured at
+# 557ms for a 32KB line, quadrupling per doubling. ffmpeg echoes the operator's
+# camera URL back in its stderr, and that whole string reaches this pattern
+# before any truncation, so the subject length is attacker-influenced. A cap
+# makes the work per offset constant. 63 is far above any real scheme (the
+# longest registered one is under 20 characters), and a longer pseudo-scheme
+# still gets its secret masked — the match simply starts from a later offset.
+URL_CREDENTIALS_PATTERN = re.compile(
+    r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
+)
 
 
 def redact_url_credentials(text: str | None) -> str | None:

+ 278 - 0
backend/app/core/oidc_env.py

@@ -0,0 +1,278 @@
+"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
+
+A declarative deployment (compose, Helm, GitOps) has no way to click through
+the settings UI, so one provider can be configured entirely from the
+environment. This module only reads and defaults; validity is decided by the
+same OIDCProviderCreate schema the API uses, so env config cannot bypass a
+check the UI enforces.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+
+from pydantic import ValidationError
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+# All four or nothing: a provider missing its secret would be written to the
+# database and then fail at authorize time, long after the operator could
+# connect the failure to a typo in their compose file.
+_REQUIRED = (
+    "BAMBUDDY_OIDC_NAME",
+    "BAMBUDDY_OIDC_ISSUER_URL",
+    "BAMBUDDY_OIDC_CLIENT_ID",
+    "BAMBUDDY_OIDC_CLIENT_SECRET",
+)
+
+_TRUTHY = {"true", "1", "yes"}
+_FALSY = {"false", "0", "no"}
+
+
+class EnvOIDCConfigError(Exception):
+    """A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
+    boolean variable's name and value -- booleans are not secret, so the message
+    is safe to log in full (unlike client_secret, which never reaches here)."""
+
+
+def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
+    """Parse a boolean env var. Absent or blank -> default (empty == unset).
+
+    strict (the default): an unrecognized non-empty value raises
+    EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
+    the wrong thing. strict=False: an unrecognized value falls back to the
+    default instead -- for a caller on a request path where a raise would be a
+    500, not a skipped startup config (see _local_login_env_bypass).
+    """
+    value = os.environ.get(key)
+    if value is None or value.strip() == "":
+        return default  # absent or blank == unset -> default, per the module's promise
+    norm = value.strip().lower()
+    if norm in _TRUTHY:
+        return True
+    if norm in _FALSY:
+        return False
+    if strict:
+        raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
+    return default
+
+
+def read_env_oidc_config() -> dict | None:
+    """The provider's fields from the environment, or None if it isn't configured.
+
+    An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
+    a compose file is a forgotten value, not an intentional empty secret. Blank
+    means blank *after* stripping, and the surviving value is stripped too: a
+    Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
+    created from a file carries a trailing newline that nothing downstream
+    rejects -- max_length is the only bound the schema puts on these four. An
+    issuer_url with a trailing newline is stored and enabled, and then fails
+    with httpx.InvalidURL on the first click of the SSO button, which is the
+    authorize-time failure the all-or-nothing rule above exists to prevent.
+    """
+    required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
+    if not all(required.values()):
+        return None
+
+    return {
+        "name": required["BAMBUDDY_OIDC_NAME"],
+        "issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
+        "client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
+        "client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
+        "scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
+        "is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
+        "auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
+        "auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
+        "email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
+        "require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
+        "icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
+        "is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
+        # A name, not an id: ids are assigned per install, so the same compose
+        # file would point at a different group on every deployment. Resolved
+        # against the database in apply_env_oidc_provider -- the reader has no
+        # session and stays dumb.
+        "default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
+    }
+
+
+# Everything the schema validates and the model stores, except client_secret --
+# that one goes through the property so it is encrypted at rest.
+_APPLIED_FIELDS = (
+    "name",
+    "issuer_url",
+    "client_id",
+    "scopes",
+    "is_enabled",
+    "auto_create_users",
+    "auto_link_existing_accounts",
+    "email_claim",
+    "require_email_verified",
+    "icon_url",
+    "is_autologin",
+    # Written on every boot, so a group that is no longer declared is cleared:
+    # the environment is the whole truth for this row, and the API lock means
+    # a lingering value could not be removed in the UI either.
+    "default_group_id",
+)
+
+
+async def apply_env_oidc_provider(db: AsyncSession) -> None:
+    """Upsert the env-managed provider, or release it when the config is gone.
+
+    Never raises: this runs during startup, and a typo in one variable -- or a
+    DB error on commit -- must not stop the app from booting. A rejected
+    config is logged and skipped.
+    """
+    try:
+        await _apply_env_oidc_provider(db)
+    except Exception as exc:  # noqa: BLE001 -- startup must survive any failure here
+        # Never str(exc): a DB error message can echo a configured value. Class only.
+        logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
+        # A commit may have half-applied; roll back so the shared session is
+        # left clean for the rest of startup. Suppressed because rollback on a
+        # wedged connection can itself raise -- and the whole point here is that
+        # nothing in this path takes the boot down. The session is discarded by
+        # the caller's `async with` regardless.
+        with contextlib.suppress(Exception):
+            await db.rollback()
+
+
+async def _apply_env_oidc_provider(db: AsyncSession) -> None:
+    # Imported here rather than at module scope: app.core is imported by the
+    # models themselves, so a top-level import would be a cycle.
+    from backend.app.models.group import Group
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.schemas.auth import OIDCProviderCreate
+
+    try:
+        config = read_env_oidc_config()
+    except EnvOIDCConfigError as exc:
+        # Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
+        # log clearly and leave any running provider as it was. Safe to log the
+        # full message -- EnvOIDCConfigError only ever carries a boolean var.
+        logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
+        return
+
+    if config is None:
+        # Nothing to look up by name any more, so the previously managed rows are
+        # found by the flag -- and then released. All of them: the upsert's sweep
+        # should keep that at one, but scalar_one_or_none() would raise
+        # MultipleResultsFound out of the lifespan the moment it isn't, and
+        # losing the boot is too steep a price for an invariant check.
+        released_rows = (
+            (await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
+        )
+        for released in released_rows:
+            # Disabled, never deleted: user_oidc_links.provider_id is FK ON
+            # DELETE CASCADE, so removing the row would unlink every bound
+            # account and the links would not come back when the variables do.
+            # The flag is cleared as well: with no config behind it, a provider
+            # the API still refuses to edit or delete would be a dead end
+            # reachable only through the database.
+            released.is_enabled = False
+            released.is_env_managed = False
+            # Cleared too, or the released row keeps a latent autologin claim:
+            # update_oidc_provider only re-runs the exclusivity sweep when a
+            # request sets is_autologin=True, so re-enabling this row in the UI
+            # would silently make it the autologin target again.
+            released.is_autologin = False
+            logger.info(
+                "BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
+                released.name,
+            )
+        if released_rows:
+            await db.commit()
+        return
+
+    # Identity is the name, which is unique on the table. Matching on the flag
+    # instead meant an operator who named the env provider after one that
+    # already existed hit that unique constraint during startup -- and this
+    # function runs in the lifespan, so the app would not boot.
+    existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
+
+    # Resolved before anything is written, so a name that matches no group
+    # leaves the running provider untouched. Refused rather than defaulted:
+    # falling back would put every auto-created user in Viewers (routes/mfa.py)
+    # for as long as the typo lives, and the API answers 422 for a
+    # default_group_id that does not exist -- env config gets the same answer.
+    group_name = config.pop("default_group", None)
+    if group_name is not None:
+        group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
+        if group is None:
+            # Spelled out because the two cases differ sharply: an existing
+            # provider keeps running on its last good config, while on a first
+            # boot nothing is created at all and the login page has no SSO
+            # button until the name matches.
+            logger.error(
+                "BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
+                group_name,
+                "previous config left running" if existing is not None else "no provider created",
+            )
+            return
+        config["default_group_id"] = group.id
+
+    try:
+        # The same schema the API uses, so env config cannot reach a state the
+        # UI would have refused (notably the SEC-1 auto-link check).
+        validated = OIDCProviderCreate(**config)
+    except ValidationError as exc:
+        # errors(include_input=False) strips the submitted values -- str(exc)
+        # embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
+        logger.error(
+            "BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
+            exc.errors(include_input=False),
+        )
+        return
+    except Exception as exc:  # noqa: BLE001 -- any rejection must be survivable
+        # Log only the exception class, never str(exc): an unexpected error here
+        # could carry a configured value in its message. Structural guarantee,
+        # not one contingent on which exceptions the schema validators raise.
+        logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
+        return
+
+    # Computed before `existing` is reassigned below: a freshly-created row is
+    # not an adoption, and a found row that was already env-managed is a
+    # routine re-apply -- only a found row that the UI created is an adoption.
+    adopted_ui_provider = existing is not None and not existing.is_env_managed
+
+    if existing is None:
+        existing = OIDCProvider(is_env_managed=True)
+        db.add(existing)
+    for field in _APPLIED_FIELDS:
+        setattr(existing, field, getattr(validated, field))
+    existing.client_secret = validated.client_secret
+    existing.is_env_managed = True
+    await db.flush()  # the id is needed by the sweeps below
+
+    # Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
+    # stays behind. Left flagged it would keep a stale issuer and secret on the
+    # login page while the API refuses every edit, disable and delete on it
+    # (409) -- the dead end reachable only through the database that the release
+    # path exists to prevent -- and the next release would find two rows and
+    # take the boot down with MultipleResultsFound. Released, not deleted, for
+    # the same cascade reason as everywhere else.
+    await db.execute(
+        update(OIDCProvider)
+        .where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
+        .values(is_env_managed=False, is_enabled=False, is_autologin=False)
+    )
+
+    if existing.is_autologin:
+        await db.execute(
+            update(OIDCProvider)
+            .where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
+            .values(is_autologin=False)
+        )
+    await db.commit()
+    if adopted_ui_provider:
+        logger.warning(
+            "Env-managed OIDC provider %r adopted an existing UI-created provider of the "
+            "same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
+            existing.name,
+        )
+    else:
+        logger.info("Env-managed OIDC provider %r applied.", existing.name)

+ 8 - 0
backend/app/main.py

@@ -6995,6 +6995,14 @@ async def lifespan(app: FastAPI):
 
     await init_db()
 
+    # After migrations, so the is_env_managed column exists. Never raises --
+    # a bad BAMBUDDY_OIDC_* value is logged and skipped rather than blocking
+    # startup (see apply_env_oidc_provider).
+    from backend.app.core.oidc_env import apply_env_oidc_provider
+
+    async with async_session() as oidc_db:
+        await apply_env_oidc_provider(oidc_db)
+
     # Register an app-scoped httpx client for Bambu Cloud services so
     # per-request BambuCloudService instances reuse the same connection pool
     # (important for routes like /cloud/filament-info that chain many

+ 4 - 0
backend/app/models/oidc_provider.py

@@ -128,6 +128,10 @@ class OIDCProvider(Base):
     # authorize-URL fetch fails or times out, and ``/login?fallback=local``
     # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
     is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    # Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
+    # startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
+    # FK is ON DELETE CASCADE).
+    is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
     @property
     def has_icon(self) -> bool:

+ 3 - 0
backend/app/schemas/auth.py

@@ -527,6 +527,9 @@ class OIDCProviderResponse(BaseModel):
     icon_url: str | None = None
     default_group_id: int | None = None
     is_autologin: bool = False  # #1589
+    # #2593 — the UI renders this provider read-only; without the flag it would
+    # offer editable fields whose writes the API then refuses with 409.
+    is_env_managed: bool = False
     # Set explicitly in the route handler from `icon_content_type is not None`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).

+ 8 - 0
backend/app/schemas/printer.py

@@ -2,6 +2,8 @@ from datetime import datetime
 
 from pydantic import BaseModel, Field, field_validator
 
+from backend.app.utils.printer_models import supports_nozzle_flow_type
+
 
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
@@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
     id: int
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
+    # Whether the model is sold with both Standard and High Flow nozzles, so a
+    # K-profile's flow type is a real choice rather than a meaningless field.
+    # Derived from the model, not from nozzle_count — see
+    # printer_models.supports_nozzle_flow_type.
+    supports_nozzle_flow_type: bool = True
     print_hours_offset: float = 0.0
     external_camera_url: str | None = None
     external_camera_type: str | None = None
@@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
             "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
+            "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "print_hours_offset": printer.print_hours_offset,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "created_at": printer.created_at,

+ 227 - 115
backend/app/services/bambu_mqtt.py

@@ -812,10 +812,17 @@ class BambuMQTTClient:
         # so that missing-serial / missing-firmware warnings fire only once per connection.
         self._ams_version_warned: set[tuple[int | str, str]] = set()
 
-        # K-profile command tracking
+        # K-profile command tracking. One entry per in-flight extrusion_cali_get,
+        # keyed by the sequence_id we sent, so two concurrent requests for
+        # different nozzle sizes can't steal each other's response (#1748).
+        # Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
         self._sequence_id: int = 0
-        self._pending_kprofile_response: asyncio.Event | None = None
-        self._kprofile_response_data: list | None = None
+        self._pending_kprofile_requests: dict[str, dict] = {}
+        # Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
+        # keyed by the sequence_id we sent. The printer echoes it back, measured
+        # on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
+        # by await_cali_ack.
+        self._pending_cali_acks: dict[str, dict | None] = {}
 
         # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
         # Key: module_name, Value: timestamp when command was sent
@@ -1616,7 +1623,24 @@ class BambuMQTTClient:
             if "command" in print_data:
                 cmd = print_data.get("command")
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
-                if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
+                if cmd in ("extrusion_cali_set", "extrusion_cali_del"):
+                    # INFO, not debug: this is the printer's verdict on a write
+                    # the user just made, and it was invisible in support
+                    # bundles for as long as it sat at DEBUG (#2718). Same
+                    # reasoning as ams_filament_drying below.
+                    logger.info(
+                        "[%s] %s response: result=%s reason=%s seq=%s",
+                        self.serial_number,
+                        cmd,
+                        print_data.get("result"),
+                        print_data.get("reason", ""),
+                        print_data.get("sequence_id"),
+                    )
+                    logger.debug("[%s] %s full response: %s", self.serial_number, cmd, print_data)
+                    ack_seq = str(print_data.get("sequence_id", ""))
+                    if ack_seq in self._pending_cali_acks:
+                        self._pending_cali_acks[ack_seq] = print_data
+                elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
                 # AMS drying responses are rare (user-initiated only) and the
                 # full payload — including `result` and any `reason` code —
@@ -5412,98 +5436,120 @@ class BambuMQTTClient:
             self._drying_targets.pop(ams_id, None)
         return True
 
+    @staticmethod
+    def _parse_kprofile_entries(filaments: list, response_nozzle: str | None, log_errors: bool) -> list[KProfile]:
+        """Build KProfile objects from an ``extrusion_cali_get`` filaments array.
+
+        The printer reports ``nozzle_diameter`` **only on the response
+        envelope** — the per-filament entries carry just setting_id,
+        filament_id, name, k_value, n_coef and cali_idx. Defaulting the
+        per-entry lookup to "0.4" therefore stamped every profile 0.4mm on
+        single-nozzle printers regardless of the installed nozzle (#1748),
+        which broke the K-Profiles display and, worse, the cali_idx cascade
+        in the inventory/Spoolman assign paths that matches on
+        nozzle_diameter. Fall back to the envelope value instead, and only
+        to "0.4" when the envelope has none either.
+
+        ``or`` rather than a dict default on purpose: it also covers an entry
+        that carries the key with an empty value, and stops ``str()`` turning
+        a missing envelope value into the literal "None".
+        """
+        profiles: list[KProfile] = []
+        for i, f in enumerate(filaments):
+            if not isinstance(f, dict):
+                continue
+            try:
+                profiles.append(
+                    KProfile(
+                        # cali_idx is the actual slot/calibration index from the printer
+                        slot_id=f.get("cali_idx", i),
+                        extruder_id=int(f.get("extruder_id", 0)),
+                        nozzle_id=str(f.get("nozzle_id", "")),
+                        nozzle_diameter=str(f.get("nozzle_diameter") or response_nozzle or "0.4"),
+                        filament_id=str(f.get("filament_id", "")),
+                        name=str(f.get("name", "")),
+                        k_value=str(f.get("k_value", "0.000000")),
+                        n_coef=str(f.get("n_coef", "0.000000")),
+                        ams_id=int(f.get("ams_id", 0)),
+                        tray_id=int(f.get("tray_id", -1)),
+                        setting_id=f.get("setting_id"),
+                    )
+                )
+            except (ValueError, TypeError) as e:
+                # Skip malformed entries; the remaining profiles stay usable.
+                # Unsolicited broadcasts arrive constantly, so only a response
+                # someone is actually waiting on is worth a warning.
+                if log_errors:
+                    logger.warning("Failed to parse K-profile: %s", e)
+                else:
+                    logger.debug("Failed to parse K-profile from broadcast: %s", e)
+        return profiles
+
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
-        response_seq_id = data.get("sequence_id", "?")
+        response_seq_id = str(data.get("sequence_id", ""))
         filaments = data.get("filaments", [])
-        expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
-        has_pending_request = self._pending_kprofile_response is not None
 
-        # Log all incoming responses when we have a pending request (for debugging)
-        if has_pending_request:
+        # Snapshot the map: the asyncio thread adds and removes entries while
+        # this MQTT callback thread walks it.
+        pending = dict(self._pending_kprofile_requests)
+        request = pending.get(response_seq_id)
+
+        if request is None and pending:
+            # Firmware that doesn't echo our sequence_id still has to be
+            # served, so fall back to the pre-#1748 rule of matching on the
+            # nozzle size. Only requests still waiting are eligible, and the
+            # sequence_id lookup above has already claimed any response that
+            # identifies itself, so this can no longer hand request A's
+            # answer to request B when both are in flight.
+            request = next(
+                (r for r in pending.values() if r["nozzle"] == response_nozzle and r["profiles"] is None),
+                None,
+            )
+
+        if pending:
             logger.info(
-                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
-                f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
+                "[%s] K-profile response: nozzle=%s, seq_id=%s, %d profiles, matched=%s",
+                self.serial_number,
+                response_nozzle,
+                response_seq_id or "?",
+                len(filaments),
+                request is not None,
             )
 
-        # If we have a pending request, only accept responses with matching nozzle_diameter
-        # The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
-        if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
-            # Ignore this broadcast, keep waiting for matching response
+        if request is None and pending:
+            # A request is outstanding and this isn't its answer. The printer
+            # broadcasts extrusion_cali_get unsolicited, so letting this
+            # through would replace state.kprofiles with another nozzle's
+            # profiles while the caller is still waiting.
             logger.debug(
-                f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}"
+                "[%s] Ignoring unmatched K-profile response: nozzle=%s, seq_id=%s",
+                self.serial_number,
+                response_nozzle,
+                response_seq_id or "?",
             )
             return
 
-        # If no pending request, this is just a broadcast - update state silently and return early
-        if not has_pending_request:
-            # Still parse profiles to keep state updated, but don't log
-            profiles = []
-            for f in filaments:
-                if isinstance(f, dict):
-                    try:
-                        cali_idx = f.get("cali_idx", 0)
-                        profiles.append(
-                            KProfile(
-                                slot_id=cali_idx,
-                                extruder_id=int(f.get("extruder_id", 0)),
-                                nozzle_id=str(f.get("nozzle_id", "")),
-                                nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
-                                filament_id=str(f.get("filament_id", "")),
-                                name=str(f.get("name", "")),
-                                k_value=str(f.get("k_value", "0.000000")),
-                                n_coef=str(f.get("n_coef", "0.000000")),
-                                ams_id=int(f.get("ams_id", 0)),
-                                tray_id=int(f.get("tray_id", -1)),
-                                setting_id=f.get("setting_id"),
-                            )
-                        )
-                    except (ValueError, TypeError):
-                        pass  # Skip malformed K-profile entries; remaining profiles still usable
-            self.state.kprofiles = profiles
-            return
+        profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
+        self.state.kprofiles = profiles
 
-        profiles = []
+        if request is None:
+            # Unsolicited broadcast with nothing in flight: state is refreshed,
+            # nobody to wake.
+            return
 
-        for i, f in enumerate(filaments):
-            if isinstance(f, dict):
-                try:
-                    # cali_idx is the actual slot/calibration index from the printer
-                    cali_idx = f.get("cali_idx", i)
-                    profiles.append(
-                        KProfile(
-                            slot_id=cali_idx,
-                            extruder_id=int(f.get("extruder_id", 0)),
-                            nozzle_id=str(f.get("nozzle_id", "")),
-                            nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
-                            filament_id=str(f.get("filament_id", "")),
-                            name=str(f.get("name", "")),
-                            k_value=str(f.get("k_value", "0.000000")),
-                            n_coef=str(f.get("n_coef", "0.000000")),
-                            ams_id=int(f.get("ams_id", 0)),
-                            tray_id=int(f.get("tray_id", -1)),
-                            setting_id=f.get("setting_id"),
-                        )
-                    )
-                except (ValueError, TypeError) as e:
-                    logger.warning("Failed to parse K-profile: %s", e)
+        logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
+        request["profiles"] = profiles
 
-        self.state.kprofiles = profiles
-        self._kprofile_response_data = profiles
-
-        # Signal that we received the response (only if we were waiting for one)
-        # Use thread-safe method since MQTT callbacks run in a different thread
-        # Capture in local var to avoid TOCTOU race: asyncio thread can clear
-        # self._pending_kprofile_response between the check and the .set() call
-        event = self._pending_kprofile_response
-        if event:
-            logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
-            if self._loop and self._loop.is_running():
-                self._loop.call_soon_threadsafe(event.set)
-            else:
-                # Fallback for when loop is not available
-                event.set()
+        # Signal the waiter. Use the thread-safe path since MQTT callbacks run
+        # in a different thread than the event loop.
+        event = request["event"]
+        if self._loop and self._loop.is_running():
+            self._loop.call_soon_threadsafe(event.set)
+        else:
+            # Fallback for when loop is not available
+            event.set()
 
     async def get_kprofiles(
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
@@ -5533,11 +5579,13 @@ class BambuMQTTClient:
             return []
 
         for attempt in range(max_retries):
-            # Set up response event for this attempt
+            # Register this attempt under its own sequence_id so a concurrent
+            # request for a different nozzle size can't consume its response
+            # (#1748) — the pending map is keyed by exactly the id we send.
             self._sequence_id += 1
-            self._pending_kprofile_response = asyncio.Event()
-            self._kprofile_response_data = None
-            self._expected_kprofile_nozzle = nozzle_diameter  # Track which nozzle response we expect
+            seq_id = str(self._sequence_id)
+            request: dict = {"nozzle": nozzle_diameter, "event": asyncio.Event(), "profiles": None}
+            self._pending_kprofile_requests[seq_id] = request
 
             # Send the command with nozzle_diameter filter
             command = {
@@ -5545,20 +5593,20 @@ class BambuMQTTClient:
                     "command": "extrusion_cali_get",
                     "filament_id": "",
                     "nozzle_diameter": nozzle_diameter,
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                 }
             }
 
             logger.info(
-                f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})"
+                f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries}, seq_id={seq_id})"
             )
             logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
-            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
-            # Wait for response (response handler already filters by nozzle_diameter)
+            # Wait for the response (the handler matches it back to this entry)
             try:
-                await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
-                profiles = self._kprofile_response_data or []
+                self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+                await asyncio.wait_for(request["event"].wait(), timeout=timeout)
+                profiles = request["profiles"] or []
                 logger.info(
                     f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
                 )
@@ -5571,12 +5619,56 @@ class BambuMQTTClient:
                     # Brief delay before retry
                     await asyncio.sleep(0.5)
             finally:
-                self._pending_kprofile_response = None
-                self._expected_kprofile_nozzle = None
+                self._pending_kprofile_requests.pop(seq_id, None)
 
         logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
         return []
 
+    def _publish_cali_write(self, command: dict, seq_id: str) -> bool:
+        """Publish a K-profile write and arm its ack slot.
+
+        Registration happens before the publish because the printer answers in
+        well under a second — measured at 70-150ms — which is comfortably
+        before an async caller gets back to awaiting.
+        """
+        self._pending_cali_acks[seq_id] = None
+        try:
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        except Exception:
+            self._pending_cali_acks.pop(seq_id, None)
+            raise
+        return True
+
+    async def await_cali_ack(self, seq_id: str, timeout: float = 6.0) -> tuple[bool, str]:
+        """Wait for the printer's verdict on a K-profile write.
+
+        Returns ``(ok, detail)``. ``ok`` is False only when the printer
+        explicitly said ``result: "fail"`` — a timeout returns True with a
+        detail string, because "no answer" is not evidence of rejection and
+        older firmware may not answer at all. Callers that need certainty read
+        the calibration table back.
+
+        Polled rather than event-driven on purpose: the ack is filled in by the
+        MQTT callback thread, and polling a dict costs one lookup every 50ms
+        for at most a few hundred milliseconds, against the cross-thread
+        event plumbing it would otherwise take.
+        """
+        deadline = time.monotonic() + timeout
+        try:
+            while time.monotonic() < deadline:
+                ack = self._pending_cali_acks.get(seq_id)
+                if ack is not None:
+                    result = str(ack.get("result", "")).lower()
+                    reason = str(ack.get("reason", "") or "")
+                    if result == "fail":
+                        return (False, reason or "printer reported failure")
+                    return (True, reason)
+                await asyncio.sleep(0.05)
+        finally:
+            self._pending_cali_acks.pop(seq_id, None)
+        logger.warning("[%s] No ack for K-profile write seq=%s within %.1fs", self.serial_number, seq_id, timeout)
+        return (True, "no acknowledgement from printer")
+
     def set_kprofile(
         self,
         filament_id: str,
@@ -5588,7 +5680,7 @@ class BambuMQTTClient:
         setting_id: str | None = None,
         slot_id: int = 0,
         cali_idx: int | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Set/update a K-profile on the printer.
 
         Args:
@@ -5603,13 +5695,16 @@ class BambuMQTTClient:
             cali_idx: For edits, the existing slot being edited (enables in-place edit)
 
         Returns:
-            True if command was sent, False otherwise
+            The sequence_id the command was sent under, so the caller can
+            await the printer's verdict via await_cali_ack. None if the
+            command could not be sent.
         """
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
-            return False
+            return None
 
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
         # Build the filament entry - printer uses cali_idx for profile identification
         # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
@@ -5637,7 +5732,13 @@ class BambuMQTTClient:
             "nozzle_diameter": nozzle_diameter,
             "nozzle_id": nozzle_id,
             "setting_id": setting_id if setting_id else "",
-            "tray_id": -1,
+            # 0, not -1. Single-nozzle firmware validates this field and
+            # answers `result: "fail", reason: "invalid tray_id"` to -1 — while
+            # applying the write anyway, so the rejection looked like noise.
+            # Measured on an X1C: flipping only this value turns the ack into
+            # `success` (#2718). BambuStudio always sends a real tray_id and
+            # defaults it to 0 for a manually entered profile.
+            "tray_id": 0,
         }
 
         command = {
@@ -5645,7 +5746,7 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "filaments": [filament_entry],
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
         }
 
@@ -5654,14 +5755,14 @@ class BambuMQTTClient:
             f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
         )
         logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
-        self._client.publish(self.topic_publish, command_json, qos=1)
-        return True
+        self._publish_cali_write(command, seq_id)
+        return seq_id
 
     def set_kprofiles_batch(
         self,
         profiles: list[dict],
         nozzle_diameter: str = "0.4",
-    ) -> bool:
+    ) -> str | None:
         """Set multiple K-profiles in a single command (for dual-nozzle).
 
         Args:
@@ -5670,15 +5771,17 @@ class BambuMQTTClient:
             nozzle_diameter: Common nozzle diameter for all profiles
 
         Returns:
-            True if command was sent, False otherwise
+            The sequence_id the command was sent under (see set_kprofile),
+            or None if it could not be sent.
         """
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
-            return False
+            return None
 
         import random
 
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
         filament_entries = []
         for p in profiles:
@@ -5706,7 +5809,9 @@ class BambuMQTTClient:
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                     "setting_id": setting_id if setting_id else "",
-                    "tray_id": -1,
+                    # See set_kprofile: -1 is rejected as "invalid tray_id" by
+                    # single-nozzle firmware even though the write lands (#2718).
+                    "tray_id": 0,
                 }
             )
 
@@ -5715,15 +5820,15 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "filaments": filament_entries,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
         }
 
         command_json = json.dumps(command)
         logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
         logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
-        self._client.publish(self.topic_publish, command_json, qos=1)
-        return True
+        self._publish_cali_write(command, seq_id)
+        return seq_id
 
     def delete_kprofile(
         self,
@@ -5733,7 +5838,7 @@ class BambuMQTTClient:
         nozzle_diameter: str = "0.4",
         extruder_id: int = 0,
         setting_id: str | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Delete a K-profile from the printer.
 
         Args:
@@ -5745,13 +5850,15 @@ class BambuMQTTClient:
             setting_id: Unique setting identifier (for X1C series)
 
         Returns:
-            True if command was sent, False otherwise
+            The sequence_id the command was sent under (see set_kprofile),
+            or None if it could not be sent.
         """
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
-            return False
+            return None
 
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
         # Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
         # single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
@@ -5767,7 +5874,7 @@ class BambuMQTTClient:
             command = {
                 "print": {
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "extruder_id": extruder_id,
                     "nozzle_id": nozzle_id,
                     "filament_id": filament_id,
@@ -5781,7 +5888,7 @@ class BambuMQTTClient:
             command = {
                 "print": {
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "filament_id": filament_id,
                     "cali_idx": cali_idx,
                     "setting_id": setting_id if setting_id else "",
@@ -5796,9 +5903,9 @@ class BambuMQTTClient:
             f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
         )
         logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
-        # Use QoS 1 for reliable delivery (at least once)
-        self._client.publish(self.topic_publish, command_json, qos=1)
-        return True
+        # QoS 1 for reliable delivery (at least once)
+        self._publish_cali_write(command, seq_id)
+        return seq_id
 
     # =========================================================================
     # Printer Control Commands
@@ -6641,6 +6748,11 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             return False
 
+        # Was reusing the previous command's id — harmless while nothing
+        # correlated on it, but the printer echoes sequence_id back and the
+        # K-profile write path now matches acks by it (#2718).
+        self._sequence_id += 1
+
         nozzle_id = f"HS00-{nozzle_diameter}"
 
         # A2L AMS-Lite: a normalised global tray (24-27) must go out as the

+ 49 - 0
backend/app/utils/printer_models.py

@@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
 )
 
 
+# Models sold with a single nozzle flow variant, so a Standard / High Flow
+# choice on a K-profile is meaningless there. Derived from the slicer's own
+# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
+# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
+# single-nozzle and all carry two variants. Only the A-series has one.
+SINGLE_NOZZLE_FLOW_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "A1",
+        "A1MINI",
+        "A2L",
+        # Internal codes
+        "N1",  # A1 Mini
+        "N2S",  # A1
+        "N9",  # A2L
+        "A04",  # A1 Mini (alternate)
+        "A11",  # A1
+        "A12",  # A1 Mini
+    ]
+)
+
+
 # Models without any external storage (MicroSD / SD card slot).
 # The A1 and A1 Mini ship with internal storage only — there is no
 # firmware-side "Store sent files on external storage" toggle and no
@@ -290,6 +312,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     return normalized in DUAL_NOZZLE_MODELS
 
 
+def supports_nozzle_flow_type(model: str | None) -> bool:
+    """Return True if the model offers a Standard / High Flow nozzle choice.
+
+    A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
+    (Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
+    profile's identity on any printer where both exist — and meaningless noise
+    on one where only a single variant is sold.
+
+    The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
+    and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
+    from the machine preset — ``support_nozzle_volume()`` is
+    ``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
+    Bambu profile evaluated against that formula puts only the A-series on the
+    "one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
+    upward at 2 or more per extruder).
+
+    Defaults to True for unknown models: offering the choice on a printer that
+    turns out to have one flow type costs the user a redundant dropdown, while
+    hiding it on one that has two makes half its calibration table
+    unreachable.
+    """
+    if not model:
+        return True
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized not in SINGLE_NOZZLE_FLOW_MODELS
+
+
 def get_rod_type(model: str | None) -> str | None:
     """Return the rod/rail type for a printer model.
 

+ 29 - 0
backend/tests/integration/test_local_login_gate.py

@@ -96,6 +96,35 @@ class TestLocalLoginGate:
         )
         assert response.status_code == 200, response.text
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unrecognized_env_value_does_not_500_the_login_path(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
+        an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
+        never raise -- env_bool is strict for the startup OIDC reader but lenient
+        here. A raise would 500 the very endpoint the bypass exists to keep open."""
+        await _enable_auth(async_client, "gateonval")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gateonval", "password": "GatePass1!"},
+        )
+        # Bypass stays off (same 401 as no env var), and crucially not a 500.
+        assert response.status_code == 401, response.text
+
+    def test_the_bypass_var_is_registered_in_the_typo_guard(self):
+        """config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
+        Unregistered, this one tells an operator who is locked out and following
+        the documented recovery that the variable they just set is not real --
+        while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
+        from backend.app.core.config import _INTENTIONAL_UNSETTINGS
+
+        assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_forgot_password_rejected_when_local_disabled(

+ 801 - 0
backend/tests/integration/test_oidc_env_apply.py

@@ -0,0 +1,801 @@
+"""Upserting the env-managed OIDC provider (#2593).
+
+Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
+never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
+recreating the provider would silently unlink every account bound to it.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.core.oidc_env import apply_env_oidc_provider
+from backend.app.models.oidc_provider import OIDCProvider
+
+REQUIRED = {
+    "BAMBUDDY_OIDC_NAME": "Keycloak",
+    "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
+    "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
+    "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
+}
+
+ALL_VARS = (
+    *REQUIRED,
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+
+
+def _configure(monkeypatch, **overrides):
+    for key, value in REQUIRED.items():
+        monkeypatch.setenv(key, value)
+    for key, value in overrides.items():
+        monkeypatch.setenv(key, value)
+
+
+async def _env_provider(db_session) -> OIDCProvider | None:
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    return result.scalar_one_or_none()
+
+
+@pytest.mark.asyncio
+async def test_creates_the_provider_from_env(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.name == "Keycloak"
+    assert provider.client_id == "bambuddy"
+    assert provider.is_env_managed is True
+    assert provider.client_secret == "s3cr3t"  # property decrypts
+
+
+@pytest.mark.asyncio
+async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
+    """The id must survive: user_oidc_links references it with ON DELETE
+    CASCADE, so a delete-recreate would unlink every bound account."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.client_id == "rotated"
+
+
+@pytest.mark.asyncio
+async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    # Looked up by name, not by the flag: releasing the provider clears the flag,
+    # and the point of this test is that the ROW survives either way.
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    provider = result.scalar_one_or_none()
+    assert provider is not None, "deleting would cascade away every account link"
+    assert provider.id == original_id
+    assert provider.is_enabled is False
+
+
+@pytest.mark.asyncio
+async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
+    """Only one provider may be the autologin target; the env one wins."""
+    ui_provider = OIDCProvider(
+        name="UI provider",
+        issuer_url="https://other.example.com",
+        client_id="ui",
+        is_autologin=True,
+    )
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+
+    await db_session.refresh(ui_provider)
+    assert (await _env_provider(db_session)).is_autologin is True
+    assert ui_provider.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
+    ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    await db_session.refresh(ui_provider)
+    assert ui_provider.is_env_managed is False
+    assert ui_provider.is_enabled is True
+    assert ui_provider.client_id == "ui"
+
+
+@pytest.mark.asyncio
+async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
+    """auto-link + unverified email is the SEC-1 account-takeover shape. The
+    schema rejects it for the UI, and env config must not be a way around that
+    -- but a bad variable must not stop the app from booting either."""
+    _configure(
+        monkeypatch,
+        BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
+        BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
+    )
+
+    await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None
+
+
+@pytest.mark.asyncio
+async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
+    """client_secret has max_length=512, so an over-long value raises
+    string_too_long. The rejection must be logged without the value: str(exc)
+    embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
+    secret = "S3CR3T" * 100  # > 512 chars -> ValidationError on client_secret
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None  # rejected, not booted-through
+    assert "rejected" in caplog.text  # the rejection was actually logged
+    assert secret not in caplog.text
+    assert "S3CR3T" not in caplog.text  # not even a fragment of the value
+
+
+# --- an unrecognized boolean is rejected, not guessed --------------------------
+# `_env_bool` used to return the default for anything outside {true,1,yes}, so
+# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
+# BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
+# refuses the config instead -- through the same clean path a bad
+# DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
+# provider that was running fine.
+
+
+@pytest.mark.asyncio
+async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original = await _env_provider(db_session)
+    original_id, original_enabled = original.id, original.is_enabled
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a typo must not release the provider"
+    assert provider.id == original_id
+    assert provider.is_enabled == original_enabled
+    assert provider.is_env_managed is True
+    assert "rejected" in caplog.text
+    assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original = await _env_provider(db_session)
+    original_id, original_enabled = original.id, original.is_enabled
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a typo must not release the provider"
+    assert provider.id == original_id
+    assert provider.is_enabled == original_enabled
+    assert provider.is_env_managed is True
+    assert "rejected" in caplog.text
+    assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
+    """The generic except branch handles anything that isn't a ValidationError
+    (e.g. a library call raising mid-construction). It must not stop boot and,
+    since such a message could carry a configured value, must log only the
+    exception class -- never str(exc)."""
+    # oidc_env imports OIDCProviderCreate inside the function (to avoid an
+    # import cycle), so patch it at its source module, not on oidc_env.
+    import backend.app.schemas.auth as auth_schemas
+
+    def _raise(**_kwargs):
+        raise RuntimeError("boom leaked-secret")
+
+    monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert await _env_provider(db_session) is None
+    assert "could not be applied" in caplog.text
+    assert "RuntimeError" in caplog.text  # class is logged...
+    assert "leaked-secret" not in caplog.text  # ...but nothing from the message
+
+
+@pytest.mark.asyncio
+async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
+    """The upsert's db.execute/db.commit calls sit outside the inner
+    ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
+    must not propagate out of the lifespan either. Only the exception class
+    may be logged, never str(exc), since a DB error message can echo a
+    configured value."""
+
+    async def _raise_on_commit():
+        raise RuntimeError("database is locked")
+
+    monkeypatch.setattr(db_session, "commit", _raise_on_commit)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert "could not be applied" in caplog.text
+    assert "RuntimeError" in caplog.text  # class is logged...
+    assert "leaked-secret" not in caplog.text  # ...but nothing from the message
+
+
+@pytest.mark.asyncio
+async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
+    """The handler rolls back after a failed commit -- but rollback on a wedged
+    connection can raise too, and 'never raises' has to hold for that as well
+    or the boot dies on the recovery path. The rollback is suppressed."""
+
+    async def _raise_on_commit():
+        raise RuntimeError("database is locked")
+
+    async def _raise_on_rollback():
+        raise RuntimeError("connection is closed")
+
+    monkeypatch.setattr(db_session, "commit", _raise_on_commit)
+    monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise, even here
+
+    assert "could not be applied" in caplog.text
+    assert "leaked-secret" not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
+    """Every boot re-applies; the second run must not create a second row."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    await apply_env_oidc_provider(db_session)
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    assert len(result.scalars().all()) == 1
+
+
+# --- identity is the name, not the flag ---------------------------------------
+# The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
+# Matching on is_env_managed instead made three things impossible: adopting a
+# provider that already carries the name (the insert hit the unique constraint
+# and took startup down with it), releasing the provider when the config goes
+# away, and finding it again afterwards.
+
+
+@pytest.mark.asyncio
+async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
+    """An operator who names the env provider after one they created in the UI
+    must not end up with an app that refuses to boot."""
+    ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+    original_id = ui_provider.id
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.id == original_id, "adopted, not duplicated"
+    assert provider.client_id == "bambuddy"
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    assert len(result.scalars().all()) == 1
+
+
+@pytest.mark.asyncio
+async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
+    """Overwriting a UI-created provider in place is a bigger deal than a
+    routine re-apply -- it must not be silent at the same INFO level."""
+    ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch)
+    with caplog.at_level(logging.INFO):
+        await apply_env_oidc_provider(db_session)
+
+    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+    assert any("adopted" in r.message for r in warnings)
+
+
+@pytest.mark.asyncio
+async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
+    """The same provider re-applying on the next boot is not an adoption --
+    it was already env-managed."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    caplog.clear()
+
+    with caplog.at_level(logging.INFO):
+        await apply_env_oidc_provider(db_session)
+
+    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+    assert not any("adopted" in r.message for r in warnings)
+
+
+@pytest.mark.asyncio
+async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
+    """Nothing manages it any more, so the API must stop refusing edits and
+    deletes -- otherwise the row is a dead end only reachable via the database."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    provider = result.scalar_one()
+    assert provider.is_enabled is False
+    assert provider.is_env_managed is False
+
+
+@pytest.mark.asyncio
+async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
+    """The account links hang off this row; a second provider would orphan them."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.is_enabled is True
+
+
+@pytest.mark.asyncio
+async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
+    monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.issuer_url == "https://sso.example.com/realms/other"
+    assert provider.client_id == "rotated"
+
+
+# --- a rename must not leave the old row managed -------------------------------
+# Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
+# creates a second row. Leaving the flag on the first one is what makes that
+# fatal: it stays enabled with a stale issuer and secret on the login page, the
+# API refuses every edit/disable/delete on it (409), and the release path's
+# scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
+# the app stops booting. Both states are reachable by ordinary config edits.
+
+
+async def _env_managed(db_session) -> list[OIDCProvider]:
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    return list(result.scalars().all())
+
+
+@pytest.mark.asyncio
+async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    managed = await _env_managed(db_session)
+    assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
+
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    # Released, not deleted -- user_oidc_links.provider_id cascades.
+    assert old.is_env_managed is False
+    assert old.is_enabled is False, "a stale issuer must not stay on the login page"
+    assert old.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
+    """The MultipleResultsFound path: rename, then unset. Must not raise."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert await _env_managed(db_session) == []
+    names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
+    assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
+
+
+@pytest.mark.asyncio
+async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
+    """The upsert's sweep should keep this at one row. Should is not enforced by
+    the schema, and the cost of being wrong is the whole release path raising
+    MultipleResultsFound out of the lifespan -- so it releases what it finds."""
+    for name in ("Keycloak", "Authentik"):
+        stale = OIDCProvider(
+            name=name,
+            issuer_url="https://sso.example.com/realms/main",
+            client_id="bambuddy",
+            is_env_managed=True,
+        )
+        stale.client_secret = "s3cr3t"
+        db_session.add(stale)
+    await db_session.commit()
+
+    await apply_env_oidc_provider(db_session)  # no vars set -> release path
+
+    assert await _env_managed(db_session) == []
+
+
+@pytest.mark.asyncio
+async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
+    """is_enabled and is_env_managed alone leave a UI-editable row carrying a
+    latent autologin claim: update_oidc_provider only runs the exclusivity
+    sweep when a request sets is_autologin=True, so merely re-enabling this row
+    makes it the autologin target again."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    assert (await _env_provider(db_session)).is_autologin is True
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
+    assert released.is_autologin is False
+
+
+# --- default group by name -----------------------------------------------------
+# Group ids are not stable across installs, so a declarative deployment cannot
+# name one by id. Without this, every auto-created user falls back to Viewers
+# (routes/mfa.py) and the env lock means the UI cannot correct the provider.
+
+
+async def _group(db_session, name: str):
+    from backend.app.models.group import Group
+
+    group = Group(name=name, description=f"Test group {name}")
+    db_session.add(group)
+    await db_session.commit()
+    return group
+
+
+@pytest.mark.asyncio
+async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
+    group = await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id == group.id
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
+    """Silently falling back to Viewers is how a typo mints under-privileged
+    users for weeks. The API answers 400 for a default_group_id that does not
+    exist; env config gets the same answer, logged and survivable."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None
+    assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
+    assert "Nope" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
+    """Rejection happens before the upsert, so the running config survives a
+    bad edit -- the provider keeps working until the operator fixes the name."""
+    group = await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+    await apply_env_oidc_provider(db_session)
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.default_group_id == group.id
+
+
+@pytest.mark.asyncio
+async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id is None
+
+
+@pytest.mark.asyncio
+async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
+    """The environment is the whole truth for this row; a group that is no
+    longer declared must not linger, since the lock blocks removing it in the UI."""
+    await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+    await apply_env_oidc_provider(db_session)
+
+    monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id is None
+
+
+@pytest.mark.asyncio
+async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
+    """Same rule the required vars follow: an empty value in a compose file is
+    a forgotten value, not a request to reject the config."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.default_group_id is None
+
+
+# --- blank optional strings count as unset, not a refusal ---------------------
+# `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
+# it must not take the provider down -- same rule default_group already follows.
+
+
+@pytest.mark.asyncio
+async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.scopes == "openid email profile"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.email_claim == "email"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.icon_url is None
+
+
+# --- account links and collision behavior ------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
+    """New name collides with existing UI provider: env config adopts that row,
+    old env-managed row is released. Identity is the name, so the collision is
+    resolved by matching the new name against the table."""
+    # Start with env-managed "Keycloak"
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+
+    # Add a UI provider named "Authentik"
+    ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+    ui_id = ui_provider.id
+
+    # Rename env provider to "Authentik" — matches the UI provider
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # The UI provider is adopted and becomes env-managed
+    provider = await _env_provider(db_session)
+    assert provider.id == ui_id, "adopted the UI provider"
+    assert provider.name == "Authentik"
+    assert provider.client_id == "bambuddy"  # updated from env
+    assert provider.is_env_managed is True
+
+    # The old Keycloak row is released
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    assert old.name == "Keycloak"
+    assert old.is_env_managed is False
+    assert old.is_enabled is False
+
+
+@pytest.mark.asyncio
+async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
+    """The provider row is never deleted, only updated: user_oidc_links FK
+    ON DELETE CASCADE must not be triggered by a rename."""
+    from backend.app.models.oidc_provider import UserOIDCLink
+    from backend.app.models.user import User
+
+    # Create a user and link it to the env-managed provider
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    provider_id = (await _env_provider(db_session)).id
+
+    user = User(username="testuser", email="test@example.com")
+    db_session.add(user)
+    await db_session.flush()
+
+    link = UserOIDCLink(
+        user_id=user.id,
+        provider_id=provider_id,
+        provider_user_id="oidc-sub-12345",
+        provider_email="test@idp.example.com",
+    )
+    db_session.add(link)
+    await db_session.commit()
+
+    # Rename the env provider
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # The link still exists, pointing to the old row (which is now released)
+    result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
+    links = result.scalars().all()
+    assert len(links) == 1
+    assert links[0].provider_user_id == "oidc-sub-12345"
+
+
+@pytest.mark.asyncio
+async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
+    """When renamed env config has autologin=true, the sweep clears autologin
+    from other rows. The old row is released (autologin cleared there too)."""
+    # Setup: env provider "Keycloak" with autologin
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+    assert (await _env_provider(db_session)).is_autologin is True
+
+    # Another UI provider also has autologin
+    ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
+    ui_provider.client_secret = "secret"
+    ui_provider.is_autologin = True
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    # Rename env provider to "Authentik" with autologin=true
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # New row is the autologin target
+    new_provider = await _env_provider(db_session)
+    assert new_provider.name == "Authentik"
+    assert new_provider.is_autologin is True
+
+    # Old row is released and autologin cleared
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    assert old.is_env_managed is False
+    assert old.is_autologin is False
+
+    # UI provider autologin is cleared (only env-managed can be autologin now)
+    await db_session.refresh(ui_provider)
+    assert ui_provider.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
+    """Group name is resolved by exact match; 'operators' != 'Operators'."""
+    await _group(db_session, "Operators")  # capital O
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators")  # lowercase
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    # Config is rejected
+    assert await _env_provider(db_session) is None
+    assert "operators" in caplog.text
+    assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
+    """Group resolution happens before schema validation, so the secret is
+    not yet in scope, but verify it's not leaked by the error path."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
+    secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    # Config is rejected but secret is safe
+    assert await _env_provider(db_session) is None
+    assert secret not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
+    """Rename Keycloak → Authentik, unset everything, restore Keycloak.
+    Must re-enable the original row, not create a new one."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    # Rename to Authentik
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+    assert (await _env_provider(db_session)).name == "Authentik"
+
+    # Unset everything
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    # Restore the original Keycloak config
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    # Same row, re-enabled
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.name == "Keycloak"
+    assert provider.is_enabled is True
+    assert provider.is_env_managed is True

+ 137 - 0
backend/tests/integration/test_oidc_env_lock.py

@@ -0,0 +1,137 @@
+"""The env-managed provider is read-only through the API (#2593).
+
+Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
+would silently disappear at the next restart -- the operator would see their
+change accepted and then reverted, with nothing explaining why. Refusing the
+write is the honest answer.
+
+Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
+recovery path if the provider itself becomes unusable.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.oidc_provider import OIDCProvider
+from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
+
+
+async def _env_managed_provider(db_session) -> int:
+    provider = OIDCProvider(
+        name="Env Keycloak",
+        issuer_url="https://sso.example.com/realms/main",
+        client_id="bambuddy",
+        icon_url="https://sso.example.com/logo.png",
+        is_env_managed=True,
+    )
+    provider.client_secret = "s3cr3t"
+    db_session.add(provider)
+    await db_session.commit()
+    await db_session.refresh(provider)
+    return provider.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_put_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockput", "envlockput123")
+
+    response = await async_client.put(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        json={"name": "hijacked"},
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_delete_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
+
+    response = await async_client.delete(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
+    """The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
+
+    response = await async_client.delete(
+        f"/api/v1/auth/oidc/providers/{provider_id}/icon",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
+
+    response = await async_client.post(
+        f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
+    """The lock must not leak onto providers the operator created themselves --
+    they coexist with the env one and stay fully editable."""
+    token = await _setup_and_login(async_client, "envlockui", "envlockui123")
+    created = await async_client.post(
+        "/api/v1/auth/oidc/providers",
+        json={
+            "name": "UI provider",
+            "issuer_url": "https://other.example.com",
+            "client_id": "ui",
+            "client_secret": "ui-secret",
+            "scopes": "openid",
+            "is_enabled": True,
+            "auto_create_users": False,
+        },
+        headers=_auth_header(token),
+    )
+    provider_id = created.json()["id"]
+
+    response = await async_client.put(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        json={"name": "Renamed"},
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 200
+    assert response.json()["name"] == "Renamed"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
+    """The frontend needs this to render the lock; without it the UI would show
+    editable fields whose writes the API then refuses."""
+    await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
+
+    response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+
+    assert response.status_code == 200
+    providers = response.json()
+    assert any(p["is_env_managed"] for p in providers)

+ 43 - 0
backend/tests/integration/test_oidc_env_startup.py

@@ -0,0 +1,43 @@
+"""The env provider is applied on startup, not merely appliable (#2593).
+
+test_oidc_env_apply.py calls apply_env_oidc_provider() directly, so it stays
+green even if nothing ever calls it -- deleting the lifespan call would leave
+the feature dead with a fully passing suite. These tests pin the call site.
+
+They read the lifespan's source rather than running it: the function is ~460
+lines and starts printer connections, MQTT and schedulers, so executing it
+here would test everything except the one line in question. That makes this a
+wiring check, not a behavioural one -- it proves the call exists and runs
+after migrations, and deliberately proves nothing about what it does. The
+behaviour is covered by test_oidc_env_apply.py.
+"""
+
+from __future__ import annotations
+
+import inspect
+
+from backend.app.main import lifespan
+
+
+def _lifespan_source() -> str:
+    return inspect.getsource(lifespan)
+
+
+def test_lifespan_applies_the_env_oidc_provider():
+    assert "apply_env_oidc_provider(" in _lifespan_source()
+
+
+def test_it_runs_after_the_migrations():
+    """is_env_managed does not exist until run_migrations has added it, so an
+    upsert before init_db() would fail on every existing installation."""
+    source = _lifespan_source()
+    assert source.index("await init_db()") < source.index("apply_env_oidc_provider(")
+
+
+def test_the_apply_call_is_awaited():
+    """apply_env_oidc_provider is a coroutine; calling it without await would
+    return an un-awaited coroutine and silently apply nothing."""
+    source = _lifespan_source()
+    call = source.index("apply_env_oidc_provider(")
+    line_start = source.rindex("\n", 0, call) + 1
+    assert source[line_start:call].strip().endswith("await")

+ 18 - 0
backend/tests/integration/test_updates_api.py

@@ -8,6 +8,24 @@ from httpx import AsyncClient
 
 
 class TestUpdatesAPI:
+    @pytest.fixture(autouse=True)
+    def _reset_update_status(self):
+        """Isolate the module-global ``_update_status`` between tests.
+
+        ``POST /updates/apply`` short-circuits (line 850) when ``_update_status``
+        is ``"downloading"``/``"installing"``, returning a payload WITHOUT the
+        per-branch keys (``is_windows_installer`` etc.). A prior test that let an
+        apply flow run leaves the global mid-update, so a later test in the same
+        parallel worker hits the guard instead of its intended branch. This is
+        order-dependent — it passes locally but flakes on CI's sharded run
+        (``test_apply_update_windows_installer_rejection`` KeyError). Reset to
+        idle before every test so the guard never fires spuriously.
+        """
+        from backend.app.api.routes import updates as updates_module
+
+        updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
+        yield
+
     @pytest.mark.asyncio
     async def test_get_version(self, async_client: AsyncClient):
         response = await async_client.get("/api/v1/updates/version")

+ 21 - 0
backend/tests/unit/services/test_bambu_cloud.py

@@ -7,6 +7,27 @@ import pytest
 from backend.app.services.bambu_cloud import BambuCloudService
 
 
+@pytest.fixture(autouse=True)
+def _stub_csrf_handshake():
+    """Keep the CSRF pre-flight off the network for every test in this module.
+
+    ``verify_totp`` fetches a CSRF token from the ``bambulab.com`` web origin
+    before posting the code (#2696), and returns early without posting when it
+    cannot get one. The tests below patch only ``post``, so that GET went out
+    over the real network: it succeeded on any machine that could reach
+    bambulab.com — which is why this file passed locally — and returned a
+    tokenless 403 on a CI runner, where six tests then failed asserting on a
+    ``post`` that never happened.
+
+    The handshake itself is covered end to end in
+    ``tests/unit/test_cloud_totp_csrf.py``, including the no-token path, so
+    stubbing it here removes a network dependency rather than any coverage.
+    """
+    with patch.object(BambuCloudService, "_fetch_csrf_token", new_callable=AsyncMock) as fetch:
+        fetch.return_value = "csrf-token-for-tests"
+        yield fetch
+
+
 class TestBambuCloudLogin:
     """Test login flow detection (email vs TOTP)."""
 

+ 298 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4,9 +4,11 @@ Tests for the BambuMQTTClient service.
 These tests focus on timelapse tracking during prints.
 """
 
+import asyncio
 import json
 import logging
 import time
+from unittest.mock import MagicMock
 
 import pytest
 
@@ -6832,6 +6834,302 @@ class TestKProfileResponseDoesNotClobberNozzle:
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
 
 
+class TestKProfileNozzleDiameterFromEnvelope:
+    """#1748: every K-profile came back as 0.4mm on single-nozzle printers.
+
+    ``extrusion_cali_get`` carries ``nozzle_diameter`` only on the response
+    envelope — the per-filament entries hold just setting_id, filament_id,
+    name, k_value, n_coef and cali_idx. The parser read the field per entry
+    with a "0.4" default, so a 0.6/0.8 nozzle's profiles were all stamped 0.4.
+    Beyond the K-Profiles display that broke the cali_idx cascade in the
+    inventory and Spoolman assign paths, which match on nozzle_diameter.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1ETEST",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _response(nozzle="0.8", entries=None, seq="48"):
+        """A verbatim-shaped extrusion_cali_get payload from the #1748 report."""
+        if entries is None:
+            entries = [
+                {
+                    "setting_id": "GFSNLS02_07",
+                    "filament_id": "GFSNL02",
+                    "name": "SUNLU PLA Matte WHITE 0.8",
+                    "k_value": "0.01750",
+                    "n_coef": "1.000",
+                    "cali_idx": 265,
+                    "is_history_setting": True,
+                }
+            ]
+        print_data = {"command": "extrusion_cali_get", "filament_id": "", "filaments": entries}
+        if nozzle is not None:
+            print_data["nozzle_diameter"] = nozzle
+        if seq is not None:
+            print_data["sequence_id"] = seq
+        return {"print": print_data}
+
+    def test_broadcast_uses_envelope_diameter(self, mqtt_client):
+        # No request in flight: the unsolicited broadcast still has to record
+        # the right diameter, because state.kprofiles is what the assign paths
+        # read when nobody has just fetched.
+        mqtt_client._process_message(self._response(nozzle="0.8"))
+        assert [p.nozzle_diameter for p in mqtt_client.state.kprofiles] == ["0.8"]
+
+    @pytest.mark.asyncio
+    async def test_awaited_response_uses_envelope_diameter(self, mqtt_client):
+        profiles = await self._fetch(mqtt_client, "0.6", self._response(nozzle="0.6", seq="7"))
+        assert [p.nozzle_diameter for p in profiles] == ["0.6"]
+
+    def test_entry_value_still_wins(self, mqtt_client):
+        # Dual-nozzle firmware does put the field on each entry; that stays
+        # authoritative, since a batch can legitimately span nozzles.
+        entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": "0.4"}]
+        mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
+
+    def test_empty_entry_value_falls_back_to_envelope(self, mqtt_client):
+        entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": ""}]
+        mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.8"
+
+    def test_no_envelope_value_falls_back_to_default(self, mqtt_client):
+        # Neither source available: keep the old default rather than let
+        # str(None) write the literal string "None" into the profile.
+        mqtt_client._process_message(self._response(nozzle=None))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
+
+    @staticmethod
+    async def _fetch(client, nozzle, response):
+        """Run get_kprofiles, feeding `response` in as the printer's answer."""
+        client.state.connected = True
+        client._client = MagicMock()
+        client._client.publish.side_effect = lambda *a, **kw: client._process_message(response)
+        return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
+
+
+class TestKProfileWriteAcks:
+    """#2718: K-profile writes were fire-and-forget.
+
+    ``set_kprofiles_batch`` published and returned True immediately, and the
+    printer's ``extrusion_cali_set`` answer was logged at DEBUG and dropped, so
+    a rejected write was reported to the user as saved. Two facts measured on
+    real hardware shape the fix: the printer echoes our ``sequence_id`` back
+    (so the ack can be correlated), and it answers ``result: "fail",
+    reason: "invalid tray_id"`` to ``tray_id: -1`` on single-nozzle firmware
+    while applying the write anyway — flipping that field to 0 is what makes
+    ``result`` trustworthy.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1CTEST",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client._client = MagicMock()
+        return client
+
+    @staticmethod
+    def _sent(client):
+        return json.loads(client._client.publish.call_args[0][1])["print"]
+
+    def test_set_sends_tray_id_zero(self, mqtt_client):
+        mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
+
+    def test_batch_sends_tray_id_zero(self, mqtt_client):
+        mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99", "name": "t", "k_value": "0.020000"}])
+        assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
+
+    def test_writers_return_their_sequence_id(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        assert seq == self._sent(mqtt_client)["sequence_id"]
+        assert seq in mqtt_client._pending_cali_acks
+
+    def test_writers_return_none_when_disconnected(self, mqtt_client):
+        mqtt_client.state.connected = False
+        assert mqtt_client.set_kprofile(filament_id="GFL99", name="t", k_value="0.02") is None
+        assert mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99"}]) is None
+        assert mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4") is None
+
+    def test_per_tray_extrusion_cali_set_advances_the_sequence_id(self, mqtt_client):
+        # It used to reuse the previous command's id, which would silently
+        # defeat the correlation the write path now depends on.
+        before = mqtt_client._sequence_id
+        mqtt_client.extrusion_cali_set(tray_id=0, k_value=0.02)
+        assert mqtt_client._sequence_id > before
+        assert self._sent(mqtt_client)["sequence_id"] == str(mqtt_client._sequence_id)
+
+    @pytest.mark.asyncio
+    async def test_failure_ack_is_reported_as_failure(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_set",
+                    "result": "fail",
+                    "reason": "invalid tray_id",
+                    "sequence_id": seq,
+                }
+            }
+        )
+        ok, detail = await mqtt_client.await_cali_ack(seq, timeout=2.0)
+        assert ok is False
+        assert detail == "invalid tray_id"
+
+    @pytest.mark.asyncio
+    async def test_success_ack_passes(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        mqtt_client._process_message(
+            {"print": {"command": "extrusion_cali_set", "result": "success", "reason": "", "sequence_id": seq}}
+        )
+        ok, _ = await mqtt_client.await_cali_ack(seq, timeout=2.0)
+        assert ok is True
+
+    @pytest.mark.asyncio
+    async def test_ack_for_another_write_does_not_resolve_this_one(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_set",
+                    "result": "fail",
+                    "reason": "invalid tray_id",
+                    "sequence_id": "999999",
+                }
+            }
+        )
+        # Unrelated sequence_id: this write is still unanswered, so it times
+        # out rather than inheriting someone else's failure.
+        ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
+        assert ok is True
+        assert "no acknowledgement" in detail
+
+    @pytest.mark.asyncio
+    async def test_silence_is_not_treated_as_rejection(self, mqtt_client):
+        # Firmware that never answers must not turn every save into an error.
+        seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
+        ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
+        assert ok is True
+        assert "no acknowledgement" in detail
+
+    @pytest.mark.asyncio
+    async def test_pending_slot_is_released(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        await mqtt_client.await_cali_ack(seq, timeout=0.3)
+        assert mqtt_client._pending_cali_acks == {}
+
+    def test_delete_ack_is_matched_too(self, mqtt_client):
+        seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
+        mqtt_client._process_message(
+            {"print": {"command": "extrusion_cali_del", "result": "success", "sequence_id": seq}}
+        )
+        assert mqtt_client._pending_cali_acks[seq]["result"] == "success"
+
+
+class TestKProfileRequestCorrelation:
+    """#1748: K-profile requests timed out whenever two were in flight.
+
+    Responses were matched to requests by nozzle diameter alone, held in one
+    shared ``_expected_kprofile_nozzle`` slot. A second request overwrote the
+    first's expectation, so the first's valid answer was discarded as a
+    mismatch and that request timed out even though the printer had replied.
+    Correlation now runs off the sequence_id we send, with the nozzle match
+    kept as a fallback for firmware that doesn't echo it.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1ETEST",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client._client = MagicMock()
+        return client
+
+    @staticmethod
+    def _response(nozzle, seq, name):
+        return {
+            "print": {
+                "command": "extrusion_cali_get",
+                "nozzle_diameter": nozzle,
+                "sequence_id": seq,
+                "filaments": [{"cali_idx": 1, "filament_id": "GFA00", "name": name, "k_value": "0.020000"}],
+            }
+        }
+
+    @pytest.mark.asyncio
+    async def test_concurrent_requests_each_get_their_own_response(self, mqtt_client):
+        # The failing sequence from the report: 0.8 is requested, then 0.4,
+        # then the 0.8 answer lands. Under nozzle-only matching the expected
+        # slot already said 0.4, so the 0.8 answer was dropped on the floor.
+        seen: list[str] = []
+
+        def publish(_topic, payload, **_kw):
+            seen.append(json.loads(payload)["print"]["sequence_id"])
+
+        mqtt_client._client.publish.side_effect = publish
+
+        big = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=5.0))
+        small = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.4", timeout=5.0))
+        await asyncio.sleep(0)  # let both publish before either answer arrives
+        assert len(seen) == 2
+
+        mqtt_client._process_message(self._response("0.8", seen[0], "wide"))
+        mqtt_client._process_message(self._response("0.4", seen[1], "narrow"))
+
+        assert [p.name for p in await big] == ["wide"]
+        assert [p.name for p in await small] == ["narrow"]
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_nozzle_match_when_sequence_id_is_not_echoed(self, mqtt_client):
+        # Firmware that answers with its own sequence_id must keep working.
+        mqtt_client._client.publish.side_effect = lambda *a, **kw: mqtt_client._process_message(
+            self._response("0.6", "9999", "echoed-nothing")
+        )
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.6", timeout=2.0)
+        assert [p.name for p in profiles] == ["echoed-nothing"]
+
+    @pytest.mark.asyncio
+    async def test_unrelated_broadcast_does_not_clobber_a_pending_fetch(self, mqtt_client):
+        # The printer broadcasts 0.4 profiles unsolicited. One arriving while a
+        # 0.8 fetch is open must neither satisfy nor overwrite it.
+        def publish(_topic, payload, **_kw):
+            seq = json.loads(payload)["print"]["sequence_id"]
+            mqtt_client._process_message(self._response("0.4", "9999", "broadcast"))
+            mqtt_client._process_message(self._response("0.8", seq, "wanted"))
+
+        mqtt_client._client.publish.side_effect = publish
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=2.0)
+        assert [p.name for p in profiles] == ["wanted"]
+        assert [p.name for p in mqtt_client.state.kprofiles] == ["wanted"]
+
+    @pytest.mark.asyncio
+    async def test_pending_entry_is_released_on_timeout(self, mqtt_client):
+        # A timed-out attempt must not leave its entry behind, or a later
+        # broadcast would be matched to a request nobody is waiting on.
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=0.01, max_retries=1)
+        assert profiles == []
+        assert mqtt_client._pending_kprofile_requests == {}
+
+
 class TestConnectRefusalReporting:
     """#2698: a refused CONNACK must leave a trace.
 

+ 38 - 0
backend/tests/unit/test_log_credential_redaction.py

@@ -10,6 +10,7 @@ into the log.
 """
 
 import asyncio
+import time
 
 from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
 from backend.app.core.logging_filters import redact_url_credentials
@@ -71,6 +72,43 @@ class TestRedactUrlCredentials:
         assert redact_url_credentials("") == ""
         assert redact_url_credentials(None) is None
 
+    def test_a_long_scheme_like_run_does_not_blow_up(self):
+        """The scheme repetition is capped so the match stays linear.
+
+        Unbounded, the engine restarted at every offset of a run of
+        scheme-legal characters and consumed to the end each time before
+        failing to find ``://`` — quadratic in the length of the line, and
+        ffmpeg echoes the operator's camera URL into the subject. An absolute
+        timing bound would be flaky, so this pins the growth rate instead:
+        doubling the input must not quadruple the work. Measured against the
+        unbounded pattern, these two inputs took 550ms and 2187ms (ratio 3.97,
+        so the assertion fails); bounded, 2.8ms and 5.4ms (ratio 1.98).
+        """
+        small = "A" * 32_000 + "://@"
+        large = "A" * 64_000 + "://@"
+
+        start = time.perf_counter()
+        assert redact_url_credentials(small) == small
+        small_elapsed = time.perf_counter() - start
+
+        start = time.perf_counter()
+        assert redact_url_credentials(large) == large
+        large_elapsed = time.perf_counter() - start
+
+        # Linear would be ~2x. Allow generous slack for a loaded CI box while
+        # still failing the ~4x of a quadratic match.
+        assert large_elapsed < max(small_elapsed * 3, 0.5)
+
+    def test_a_scheme_longer_than_the_cap_still_gets_its_secret_masked(self):
+        """The cap bounds backtracking; it must not create a redaction hole.
+
+        A pseudo-scheme longer than the cap simply matches from a later
+        offset, so the password is still replaced.
+        """
+        result = redact_url_credentials("Z" * 100 + "://user:hunter2@host/path")
+        assert "hunter2" not in result
+        assert result.endswith("://user:[REDACTED]@host/path")
+
 
 class TestFfmpegStderrFunnel:
     """`_summarize_ffmpeg_stderr` is the one funnel every stderr log in the

+ 126 - 0
backend/tests/unit/test_oidc_env_managed_migration.py

@@ -0,0 +1,126 @@
+"""The is_env_managed column has to reach databases that already exist (#2593).
+
+The model test covers a table freshly created from metadata, which is not how
+an upgrade arrives: an installed instance has an oidc_providers table without
+the column, and only run_migrations adds it there. Every boot re-runs the whole
+migration set, so adding it twice must be a no-op rather than an error.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import Base, run_migrations
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture
+async def engine():
+    """A database as it stands before this change: every table created from the
+    models, then the new column dropped again -- the model already declares it,
+    so only removing it reproduces what an installed instance actually has."""
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("ALTER TABLE oidc_providers DROP COLUMN is_env_managed"))
+    yield eng
+    await eng.dispose()
+
+
+async def _columns(conn) -> set[str]:
+    rows = await conn.execute(text("PRAGMA table_info(oidc_providers)"))
+    return {r[1] for r in rows}
+
+
+@pytest.mark.asyncio
+async def test_migration_adds_the_column_to_an_existing_table(engine):
+    async with engine.connect() as conn:
+        assert "is_env_managed" not in await _columns(conn)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert "is_env_managed" in await _columns(conn)
+
+
+@pytest.mark.asyncio
+async def test_existing_rows_default_to_not_env_managed(engine):
+    """A provider created through the UI before the upgrade must not come back
+    locked -- is_env_managed decides whether the API refuses to edit it."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO oidc_providers"
+                " (id, name, issuer_url, client_id, client_secret, scopes, is_enabled,"
+                "  auto_create_users, auto_link_existing_accounts, email_claim,"
+                "  require_email_verified)"
+                " VALUES (1, 'UI provider', 'https://sso.example', 'app', 'enc',"
+                "  'openid email profile', 1, 0, 0, 'email', 1)"
+            )
+        )
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        row = await conn.execute(text("SELECT is_env_managed FROM oidc_providers WHERE id = 1"))
+        assert not row.scalar()
+
+
+@pytest.mark.asyncio
+async def test_it_is_idempotent(engine):
+    """Every boot re-runs the migration set."""
+    for _ in range(2):
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert "is_env_managed" in await _columns(conn)

+ 12 - 0
backend/tests/unit/test_oidc_env_provider.py

@@ -0,0 +1,12 @@
+import pytest
+
+from backend.app.models.oidc_provider import OIDCProvider
+
+
+@pytest.mark.asyncio
+async def test_is_env_managed_defaults_false(db_session):
+    p = OIDCProvider(name="x", issuer_url="https://i", client_id="c", client_secret="s")
+    db_session.add(p)
+    await db_session.commit()
+    await db_session.refresh(p)
+    assert p.is_env_managed is False

+ 248 - 0
backend/tests/unit/test_oidc_env_reader.py

@@ -0,0 +1,248 @@
+"""BAMBUDDY_OIDC_* reader (#2593).
+
+The reader is deliberately dumb: it maps env vars to field names and applies
+defaults. Whether the resulting provider is *valid* is decided later, by the
+same OIDCProviderCreate schema the API uses, so env config cannot bypass a
+check the UI enforces.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.core.oidc_env import EnvOIDCConfigError, env_bool, read_env_oidc_config
+
+REQUIRED = {
+    "BAMBUDDY_OIDC_NAME": "Keycloak",
+    "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
+    "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
+    "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
+}
+
+OPTIONAL = (
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    for key in (*REQUIRED, *OPTIONAL):
+        monkeypatch.delenv(key, raising=False)
+
+
+def _set_required(monkeypatch):
+    for key, value in REQUIRED.items():
+        monkeypatch.setenv(key, value)
+
+
+def test_returns_none_when_nothing_is_configured():
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("missing", sorted(REQUIRED))
+def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missing):
+    """All four or nothing -- a half-configured provider must not reach the
+    database, where it would fail at authorize time instead of at startup."""
+    _set_required(monkeypatch)
+    monkeypatch.delenv(missing)
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("raw", ["", "   ", "\n", " \t\n "])
+@pytest.mark.parametrize("key", sorted(REQUIRED))
+def test_an_empty_required_var_counts_as_unset(monkeypatch, key, raw):
+    """`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value,
+    not an intentional empty secret -- and neither is one holding only
+    whitespace, which the optional vars have always treated as unset."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv(key, raw)
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("key", sorted(REQUIRED))
+def test_a_required_var_is_stripped(monkeypatch, key):
+    """A Kubernetes Secret written as a block scalar carries a trailing
+    newline, and the schema bounds these four by max_length only -- so an
+    unstripped issuer_url reaches the database, enables the SSO button and
+    then raises httpx.InvalidURL on the first click, long after startup could
+    have refused it."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv(key, f"  {REQUIRED[key]}\n")
+
+    cfg = read_env_oidc_config()
+    field = {
+        "BAMBUDDY_OIDC_NAME": "name",
+        "BAMBUDDY_OIDC_ISSUER_URL": "issuer_url",
+        "BAMBUDDY_OIDC_CLIENT_ID": "client_id",
+        "BAMBUDDY_OIDC_CLIENT_SECRET": "client_secret",
+    }[key]
+    assert cfg[field] == REQUIRED[key]
+
+
+def test_reads_the_required_vars(monkeypatch):
+    _set_required(monkeypatch)
+    cfg = read_env_oidc_config()
+    assert cfg["name"] == "Keycloak"
+    assert cfg["issuer_url"] == "https://sso.example.com/realms/main"
+    assert cfg["client_id"] == "bambuddy"
+    assert cfg["client_secret"] == "s3cr3t"
+
+
+def test_applies_the_documented_defaults(monkeypatch):
+    _set_required(monkeypatch)
+    cfg = read_env_oidc_config()
+    assert cfg["scopes"] == "openid email profile"
+    assert cfg["is_enabled"] is True
+    assert cfg["auto_create_users"] is False
+    assert cfg["auto_link_existing_accounts"] is False
+    assert cfg["email_claim"] == "email"
+    assert cfg["require_email_verified"] is True
+    assert cfg["icon_url"] is None
+    assert cfg["is_autologin"] is False
+
+
+@pytest.mark.parametrize("raw", ["true", "TRUE", "True", "1", "yes", "YES", " yes "])
+def test_booleans_accept_the_project_truthy_spellings(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    assert read_env_oidc_config()["auto_create_users"] is True
+
+
+@pytest.mark.parametrize("raw", ["false", "FALSE", "False", "0", "no", "NO"])
+def test_falsy_values_are_false(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    assert read_env_oidc_config()["auto_create_users"] is False
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+def test_an_unrecognized_boolean_is_rejected(monkeypatch, raw):
+    """Only the documented spellings are accepted; an unrecognised value must
+    not silently turn a flag on or off -- it must refuse the whole config
+    instead of guessing (M-R4 strict boolean parsing)."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    with pytest.raises(EnvOIDCConfigError, match="BAMBUDDY_OIDC_AUTO_CREATE_USERS"):
+        read_env_oidc_config()
+
+
+def test_a_boolean_default_of_true_can_be_turned_off(monkeypatch):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "false")
+    assert read_env_oidc_config()["require_email_verified"] is False
+
+
+# --- env_bool, tested directly ------------------------------------------------
+# The reader-level tests above pin the contract through read_env_oidc_config;
+# these exercise the helper itself so its default/blank/reject behavior is
+# proven independently of any particular BAMBUDDY_OIDC_* field.
+
+
+@pytest.mark.parametrize("raw", ["false", "FALSE", "0", "no", "NO"])
+def test_env_bool_falsy_values_are_false(monkeypatch, raw):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", True) is False
+
+
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_absent_is_the_given_default(monkeypatch, default):
+    monkeypatch.delenv("SOME_FLAG", raising=False)
+    assert env_bool("SOME_FLAG", default) is default
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_blank_is_the_given_default(monkeypatch, raw, default):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", default) is default
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+def test_env_bool_rejects_an_unrecognized_value(monkeypatch, raw):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    with pytest.raises(EnvOIDCConfigError, match="SOME_FLAG"):
+        env_bool("SOME_FLAG", True)
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_lenient_falls_back_to_default_on_unrecognized(monkeypatch, raw, default):
+    """strict=False (the request-path callers like BAMBUDDY_LOCAL_LOGIN): an
+    unrecognized value must return the default, never raise -- a raise there
+    would 500 a live endpoint rather than skip a startup config."""
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", default, strict=False) is default
+
+
+def test_optional_strings_override_their_defaults(monkeypatch):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", "openid profile groups")
+    monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", "mail")
+    monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", "https://sso.example.com/logo.png")
+    cfg = read_env_oidc_config()
+    assert cfg["scopes"] == "openid profile groups"
+    assert cfg["email_claim"] == "mail"
+    assert cfg["icon_url"] == "https://sso.example.com/logo.png"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_scopes_is_unset(monkeypatch, raw):
+    """`BAMBUDDY_OIDC_SCOPES=` in a compose file is a forgotten value, not a
+    request for a provider with no scopes -- same rule as default_group."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", raw)
+    assert read_env_oidc_config()["scopes"] == "openid email profile"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_email_claim_is_unset(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", raw)
+    assert read_env_oidc_config()["email_claim"] == "email"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_icon_url_is_unset(monkeypatch, raw):
+    """Uncommenting `# BAMBUDDY_OIDC_ICON_URL=` in .env.example must not take
+    the provider down -- the reader must still return a config, not refuse it."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", raw)
+    cfg = read_env_oidc_config()
+    assert cfg is not None, "a blank optional var must not refuse the whole provider"
+    assert cfg["icon_url"] is None
+
+
+def test_the_default_group_is_read_as_a_name(monkeypatch):
+    """A name, not an id: group ids differ per install, so an id in a compose
+    file would point at whatever group happened to be created third."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Operators")
+    cfg = read_env_oidc_config()
+    assert cfg["default_group"] == "Operators"
+    assert "default_group_id" not in cfg, "resolution needs the database, not the reader"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_default_group_is_unset(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", raw)
+    assert read_env_oidc_config()["default_group"] is None
+
+
+def test_every_var_the_reader_knows_is_registered_in_the_typo_guard():
+    """An unregistered BAMBUDDY_* var logs "possible typo" at every boot, which
+    would tell operators their correct config is wrong. Asserted against the
+    reader's own vars rather than a copied list, so a var added later is caught
+    here instead of in someone's logs."""
+    from backend.app.core.config import _INTENTIONAL_UNSETTINGS
+
+    unregistered = {v for v in (*REQUIRED, *OPTIONAL) if v not in _INTENTIONAL_UNSETTINGS}
+    assert not unregistered

+ 10 - 1
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -436,7 +436,16 @@ def test_ha_guard_keeps_ipv6_literals_bracketed():
     assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
 
 
-@pytest.mark.parametrize("ip", ["169.254.169.254", "100.100.100.200", "fd00:ec2::254", "0.0.0.0", "239.255.255.250"])
+@pytest.mark.parametrize(
+    "ip",
+    [
+        "169.254.169.254",
+        "100.100.100.200",
+        "fd00:ec2::254",
+        "0.0.0.0",  # nosec B104 — rejection fixture, not a bind address: the assertion below is that the guard refuses it
+        "239.255.255.250",
+    ],
+)
 def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
     """Tasmota keeps its own stricter rule (bare IP literals only, loopback
     rejected — a plug is always a separate LAN device), but must not miss the

+ 37 - 0
backend/tests/unit/test_printer_models.py

@@ -14,6 +14,7 @@ from backend.app.utils.printer_models import (
     is_dual_nozzle_model,
     normalize_printer_model,
     normalize_printer_model_id,
+    supports_nozzle_flow_type,
 )
 
 
@@ -213,6 +214,42 @@ class TestDualNozzleModel:
         assert is_dual_nozzle_model("") is False
 
 
+class TestSupportsNozzleFlowType:
+    """Which models offer a Standard / High Flow choice on a K-profile.
+
+    Mirrors the slicer's own rule — BambuStudio/OrcaSlicer gate their
+    Nozzle-Flow control on ``len(nozzle_volume) // len(nozzle_diameter) > 1``
+    read from the machine preset. Evaluated over every bundled Bambu profile,
+    only the A-series lands on one variant. Getting this wrong in the
+    permissive direction shows a redundant dropdown; getting it wrong in the
+    other direction makes half a printer's calibration table unreachable.
+    """
+
+    def test_a_series_has_one_flow_variant(self):
+        for model in ("A1", "A1 Mini", "A1MINI", "A2L"):
+            assert supports_nozzle_flow_type(model) is False, model
+
+    def test_a_series_internal_codes(self):
+        for code in ("N1", "N2S", "N9", "A04", "A11", "A12"):
+            assert supports_nozzle_flow_type(code) is False, code
+
+    def test_single_nozzle_models_still_offer_both_flows(self):
+        # The split is NOT nozzle count: all of these are single-nozzle and
+        # all carry two nozzle_volume variants in their machine preset.
+        for model in ("X1", "X1C", "X1E", "P1P", "P1S", "P2S", "H2S"):
+            assert supports_nozzle_flow_type(model) is True, model
+
+    def test_dual_nozzle_models_offer_both_flows(self):
+        for model in ("H2D", "H2D Pro", "H2C"):
+            assert supports_nozzle_flow_type(model) is True, model
+
+    def test_unknown_and_empty_default_to_supported(self):
+        # Fail open: a redundant dropdown beats an unreachable half-table.
+        assert supports_nozzle_flow_type(None) is True
+        assert supports_nozzle_flow_type("") is True
+        assert supports_nozzle_flow_type("SomeFuturePrinter") is True
+
+
 class TestHasExternalStorage:
     """Pins which Bambu models have a MicroSD slot. The connection
     diagnostic flips its ``external_storage`` check from ``fail`` to

+ 33 - 10
backend/tests/unit/test_printer_offline_notification.py

@@ -23,11 +23,34 @@ import pytest
 from backend.app import main as main_module
 
 
+def _spawn_patch():
+    """Patch `spawn_background_task` so the coroutine handed to it is closed.
+
+    `on_printer_status_change` builds `reconcile_stale_active_prints(...)` as
+    a call argument, so the coroutine object is constructed whether or not the
+    replacement schedules it. A bare `MagicMock` keeps it alive in `call_args`
+    and it finalises unawaited during some *later* test's GC, surfacing as a
+    `PytestUnraisableExceptionWarning` attributed to an unrelated file.
+    Closing it here mirrors the real helper taking ownership of the coroutine,
+    while still keeping reconciliation from actually running.
+    """
+    return patch(
+        "backend.app.main.spawn_background_task",
+        side_effect=lambda coro, **kwargs: coro.close(),
+    )
+
+
 def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
-    """Minimal PrinterState stub. `state="IDLE"` keeps the reconcile-edge
-    branch quiescent (it only fires on `connected=True` with a non-unknown
-    state-string, which we exercise separately) but otherwise lets the
-    handler thread through without doing extra DB / WS work."""
+    """Minimal PrinterState stub.
+
+    `state="IDLE"` is a *known* state, so on `connected=True` this does trip
+    the reconcile-edge branch in `on_printer_status_change` — that is why
+    every handler test patches the spawn helper via `_spawn_patch()`. These
+    tests assert on the offline-notification edge only; reconciliation
+    behaviour is pinned separately in
+    `test_reconcile_stale_active_prints.py`. The remaining fields just let
+    the handler thread through without extra DB / WS work.
+    """
     return SimpleNamespace(
         connected=connected,
         state=state,
@@ -170,7 +193,7 @@ class TestOfflineEdgeDetection:
             patch("backend.app.main.ws_manager", ws_mgr),
             patch("backend.app.main.mqtt_relay", relay),
             patch("backend.app.main.printer_manager", pm),
-            patch("backend.app.main.spawn_background_task"),
+            _spawn_patch(),
             patch("backend.app.main.printer_state_to_dict", return_value={}),
         ):
             await main_module.on_printer_status_change(1, _state(connected=True))
@@ -186,7 +209,7 @@ class TestOfflineEdgeDetection:
             patch("backend.app.main.ws_manager", ws_mgr),
             patch("backend.app.main.mqtt_relay", relay),
             patch("backend.app.main.printer_manager", pm),
-            patch("backend.app.main.spawn_background_task"),
+            _spawn_patch(),
             patch("backend.app.main.printer_state_to_dict", return_value={}),
         ):
             await main_module.on_printer_status_change(1, _state(connected=False))
@@ -200,7 +223,7 @@ class TestOfflineEdgeDetection:
             patch("backend.app.main.ws_manager", ws_mgr),
             patch("backend.app.main.mqtt_relay", relay),
             patch("backend.app.main.printer_manager", pm),
-            patch("backend.app.main.spawn_background_task"),
+            _spawn_patch(),
             patch("backend.app.main._maybe_notify_printer_offline", new=AsyncMock()),
             patch("backend.app.main.printer_state_to_dict", return_value={}),
         ):
@@ -218,7 +241,7 @@ class TestOfflineEdgeDetection:
             patch("backend.app.main.ws_manager", ws_mgr),
             patch("backend.app.main.mqtt_relay", relay),
             patch("backend.app.main.printer_manager", pm),
-            patch("backend.app.main.spawn_background_task"),
+            _spawn_patch(),
             patch("backend.app.main._maybe_notify_printer_offline", new=AsyncMock()),
             patch("backend.app.main.printer_state_to_dict", return_value={}),
         ):
@@ -243,7 +266,7 @@ class TestOfflineEdgeDetection:
             patch("backend.app.main.ws_manager", ws_mgr),
             patch("backend.app.main.mqtt_relay", relay),
             patch("backend.app.main.printer_manager", pm),
-            patch("backend.app.main.spawn_background_task"),
+            _spawn_patch(),
             patch("backend.app.main._maybe_notify_printer_offline", new=AsyncMock()),
             patch("backend.app.main.printer_state_to_dict", return_value={}),
         ):
@@ -315,7 +338,7 @@ class TestProgressMilestoneSessionHygiene:
             patch("backend.app.main.ws_manager", ws_mgr),
             patch("backend.app.main.mqtt_relay", relay),
             patch("backend.app.main.printer_manager", pm),
-            patch("backend.app.main.spawn_background_task"),
+            _spawn_patch(),
             patch("backend.app.main.printer_state_to_dict", return_value={}),
             patch("backend.app.main.async_session", side_effect=lambda: _SessionCM()),
             patch("backend.app.main._capture_snapshot_for_notification", new=_snap),

+ 1 - 485
frontend/package-lock.json

@@ -597,448 +597,6 @@
         "tslib": "^2.4.0"
       }
     },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
-      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
-      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
-      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
-      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
-      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
-      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
-      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
-      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
-      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
-      "cpu": [
-        "arm"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
-      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
-      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
-      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
-      "cpu": [
-        "loong64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
-      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
-      "cpu": [
-        "mips64el"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
-      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
-      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
-      "cpu": [
-        "riscv64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
-      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
-      "cpu": [
-        "s390x"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
-      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
-      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
-      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
-      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
-      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
-      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
-      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
-      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
-      "cpu": [
-        "arm64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
-      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
-      "cpu": [
-        "ia32"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
-      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
-      "cpu": [
-        "x64"
-      ],
-      "dev": true,
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
     "node_modules/@eslint-community/eslint-utils": {
       "version": "4.9.1",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -4341,49 +3899,6 @@
         "benchmarks"
       ]
     },
-    "node_modules/esbuild": {
-      "version": "0.28.1",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
-      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
-      "dev": true,
-      "hasInstallScript": true,
-      "optional": true,
-      "peer": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.28.1",
-        "@esbuild/android-arm": "0.28.1",
-        "@esbuild/android-arm64": "0.28.1",
-        "@esbuild/android-x64": "0.28.1",
-        "@esbuild/darwin-arm64": "0.28.1",
-        "@esbuild/darwin-x64": "0.28.1",
-        "@esbuild/freebsd-arm64": "0.28.1",
-        "@esbuild/freebsd-x64": "0.28.1",
-        "@esbuild/linux-arm": "0.28.1",
-        "@esbuild/linux-arm64": "0.28.1",
-        "@esbuild/linux-ia32": "0.28.1",
-        "@esbuild/linux-loong64": "0.28.1",
-        "@esbuild/linux-mips64el": "0.28.1",
-        "@esbuild/linux-ppc64": "0.28.1",
-        "@esbuild/linux-riscv64": "0.28.1",
-        "@esbuild/linux-s390x": "0.28.1",
-        "@esbuild/linux-x64": "0.28.1",
-        "@esbuild/netbsd-arm64": "0.28.1",
-        "@esbuild/netbsd-x64": "0.28.1",
-        "@esbuild/openbsd-arm64": "0.28.1",
-        "@esbuild/openbsd-x64": "0.28.1",
-        "@esbuild/openharmony-arm64": "0.28.1",
-        "@esbuild/sunos-x64": "0.28.1",
-        "@esbuild/win32-arm64": "0.28.1",
-        "@esbuild/win32-ia32": "0.28.1",
-        "@esbuild/win32-x64": "0.28.1"
-      }
-    },
     "node_modules/escalade": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5412,6 +4927,7 @@
           "url": "https://github.com/sponsors/nodeca"
         }
       ],
+      "license": "MIT",
       "dependencies": {
         "argparse": "^2.0.1"
       },

+ 91 - 0
frontend/src/__tests__/components/OIDCProviderSettings.test.tsx

@@ -325,3 +325,94 @@ describe('OIDCProviderSettings', () => {
     });
   });
 });
+
+describe('env-managed provider (#2593)', () => {
+  const envManagedProvider = {
+    ...mockProviders[0],
+    id: 2,
+    name: 'EnvIdP',
+    is_env_managed: true,
+  };
+
+  it('marks the provider as environment managed', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+    });
+    expect(screen.getByText(/Environment Managed/i)).toBeInTheDocument();
+  });
+
+  it('offers no edit or delete control for it', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+    });
+    // Startup rewrites this row from the environment on every boot, and the API
+    // answers 409 — offering the controls would promise an edit that cannot land.
+    expect(screen.queryByTestId('edit-provider-2')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('delete-provider-2')).not.toBeInTheDocument();
+  });
+
+  it('offers no icon controls for it either', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([
+          { ...envManagedProvider, icon_url: 'https://idp.example.com/i.png', has_icon: true },
+        ])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+    });
+    // Both icon routes answer 409 for an env-managed provider, so a click could
+    // only ever produce an error toast — the same reason the rest are hidden.
+    expect(screen.queryByTestId('refresh-icon-2')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('remove-icon-2')).not.toBeInTheDocument();
+  });
+
+  it('still offers them for a UI-created provider', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([{ ...mockProviders[0], is_env_managed: false }])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('TestIdP')).toBeInTheDocument();
+    });
+    expect(screen.getByTestId('edit-provider-1')).toBeInTheDocument();
+    expect(screen.getByTestId('delete-provider-1')).toBeInTheDocument();
+  });
+
+  it('hides the enable/disable toggle for env-managed providers', async () => {
+    server.use(
+      http.get('/api/v1/auth/oidc/providers/all', () =>
+        HttpResponse.json([
+          { ...mockProviders[0], id: 2, name: 'EnvIdP', is_enabled: true, is_env_managed: true },
+          { ...mockProviders[0], id: 3, name: 'UiIdP', is_enabled: true, is_env_managed: false },
+        ])
+      )
+    );
+    render(<OIDCProviderSettings />);
+
+    await waitFor(() => {
+      expect(screen.getByText('EnvIdP')).toBeInTheDocument();
+      expect(screen.getByText('UiIdP')).toBeInTheDocument();
+    });
+    // The toggle carries no testid, so it is counted: two cards are rendered and
+    // exactly one switch may exist — the UI provider's. Enabling the env-managed
+    // one would be reverted by the next boot, and the API answers 409.
+    expect(screen.getAllByRole('switch')).toHaveLength(1);
+  });
+});

+ 54 - 0
frontend/src/__tests__/hooks/useCancellableTimeout.test.ts

@@ -0,0 +1,54 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useCancellableTimeout } from '../../hooks/useCancellableTimeout';
+
+describe('useCancellableTimeout', () => {
+  beforeEach(() => vi.useFakeTimers());
+  afterEach(() => vi.useRealTimers());
+
+  it('runs the callback after the delay', () => {
+    const fn = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1500));
+    expect(fn).not.toHaveBeenCalled();
+    act(() => void vi.advanceTimersByTime(1500));
+    expect(fn).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not run the callback after unmount', () => {
+    // The bug this exists for: a modal that defers its own close by 1.5s fired
+    // setState and onClose after the component was gone — which throws outright
+    // once the DOM around it has been torn down.
+    const fn = vi.fn();
+    const { result, unmount } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1500));
+    unmount();
+    act(() => void vi.advanceTimersByTime(5000));
+    expect(fn).not.toHaveBeenCalled();
+  });
+
+  it('cancel() stops a pending callback', () => {
+    const fn = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(fn, 1000));
+    act(() => result.current.cancel());
+    act(() => void vi.advanceTimersByTime(2000));
+    expect(fn).not.toHaveBeenCalled();
+  });
+
+  it('scheduling again replaces the pending callback', () => {
+    const first = vi.fn();
+    const second = vi.fn();
+    const { result } = renderHook(() => useCancellableTimeout());
+    act(() => result.current.schedule(first, 1000));
+    act(() => result.current.schedule(second, 1000));
+    act(() => void vi.advanceTimersByTime(1000));
+    expect(first).not.toHaveBeenCalled();
+    expect(second).toHaveBeenCalledTimes(1);
+  });
+
+  it('is safe to cancel when nothing is pending', () => {
+    const { result } = renderHook(() => useCancellableTimeout());
+    expect(() => act(() => result.current.cancel())).not.toThrow();
+  });
+});

+ 0 - 35
frontend/src/__tests__/i18n/locales.test.ts

@@ -1,35 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import en from '../../i18n/locales/en';
-import de from '../../i18n/locales/de';
-
-/**
- * Recursively extracts all keys from a nested object as dot-notation paths.
- * Example: { foo: { bar: 'baz' } } => ['foo.bar']
- */
-const getKeys = (obj: object, prefix = ''): string[] => {
-  return Object.entries(obj).flatMap(([key, value]) => {
-    const path = prefix ? `${prefix}.${key}` : key;
-    return typeof value === 'object' && value !== null
-      ? getKeys(value, path)
-      : [path];
-  });
-};
-
-describe('i18n locale parity', () => {
-  const enKeys = new Set(getKeys(en));
-  const deKeys = new Set(getKeys(de));
-
-  it('German locale has all English keys', () => {
-    const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();
-    expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);
-  });
-
-  it('English locale has all German keys', () => {
-    const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();
-    expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);
-  });
-
-  it('both locales have the same number of keys', () => {
-    expect(enKeys.size).toBe(deKeys.size);
-  });
-});

+ 288 - 1
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -3,7 +3,7 @@
  */
 
 import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { QueuePage } from '../../pages/QueuePage';
@@ -197,6 +197,293 @@ describe('QueuePage', () => {
       });
     });
 
+    it('shows one if-started-now ETA for an eligible pending item', async () => {
+      // Printer 1 is free: nothing is printing on it and nothing is queued ahead.
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([mockQueueItems[0]]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Test Print 1');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      const etaEl = within(row as HTMLElement).getAllByTestId('queue-item-eta');
+      expect(etaEl).toHaveLength(1);
+      // The tooltip must say what the number actually means, not reuse the
+      // printers-page "Estimated completion time" wording (which this is not).
+      expect(etaEl[0]).toHaveAttribute(
+        'title',
+        'Completion time if this job started now',
+      );
+    });
+
+    // The scheduler only writes waiting_reason on the model-based assignment
+    // path, so an item pinned to a specific printer carries no marker at all
+    // while it sits behind a running job. Without the printer-busy check every
+    // one of these quoted the same wrong "starts now" time.
+    it('does not show an ETA for an item pinned behind a running print', async () => {
+      render(<QueuePage />);
+
+      // mockQueueItems[1] ("Active Print") is printing on printer 1, and
+      // "Test Print 1" is pending on the same printer with waiting_reason null.
+      const name = await screen.findByText('Test Print 1');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('shows the ETA only on the next item up when several share an idle printer', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            { ...mockQueueItems[0], id: 10, position: 1, archive_name: 'First up' },
+            { ...mockQueueItems[0], id: 11, position: 2, archive_name: 'Second up' },
+            { ...mockQueueItems[0], id: 12, position: 3, archive_name: 'Third up' },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      await screen.findByText('Third up');
+
+      const etaRowNames = screen
+        .queryAllByTestId('queue-item-eta')
+        .map((el) => el.closest('.group')?.textContent);
+
+      expect(etaRowNames).toHaveLength(1);
+      expect(etaRowNames[0]).toContain('First up');
+    });
+
+    it('shows an ETA for a staged item queued behind others on an idle printer', async () => {
+      // The scheduler skips manual-start items without claiming the printer, so
+      // a staged job is startable whenever its printer is free — queue order
+      // does not gate it.
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            { ...mockQueueItems[0], id: 20, position: 1, archive_name: 'Auto first' },
+            {
+              ...mockQueueItems[0],
+              id: 21,
+              position: 2,
+              archive_name: 'Staged second',
+              manual_start: true,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Staged second');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).getAllByTestId('queue-item-eta'),
+      ).toHaveLength(1);
+    });
+
+    it('does not show an ETA for an item conditional on a previous print', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Conditional Print',
+              require_previous_success: true,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Conditional Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('advances the ETA as time passes', async () => {
+      vi.useFakeTimers({ shouldAdvanceTime: true });
+      vi.setSystemTime(new Date('2026-08-02T10:00:00Z'));
+
+      try {
+        server.use(
+          http.get('/api/v1/queue/', () => {
+            return HttpResponse.json([mockQueueItems[0]]);
+          }),
+        );
+
+        render(<QueuePage />);
+
+        const name = await screen.findByText('Test Print 1');
+        const row = name.closest('.group') as HTMLElement;
+        const before = within(row).getByTestId('queue-item-eta').textContent;
+
+        // The queue payload never changes, so react-query hands back the same
+        // object and nothing here re-renders on its own. Only the page's own
+        // clock can move this value.
+        await vi.advanceTimersByTimeAsync(45 * 60 * 1000);
+
+        await waitFor(() => {
+          expect(
+            within(row).getByTestId('queue-item-eta').textContent,
+          ).not.toBe(before);
+        });
+      } finally {
+        vi.useRealTimers();
+      }
+    });
+
+    it('shows one if-started-now ETA for a staged item', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Staged Print',
+              manual_start: true,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Staged Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).getAllByTestId('queue-item-eta'),
+      ).toHaveLength(1);
+    });
+
+    it('shows exactly one live ETA for a printing item', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', ({ params }) => {
+          return HttpResponse.json({
+            id: Number(params.id),
+            name: 'Test Printer',
+            connected: true,
+            state: 'RUNNING',
+            progress: 50,
+            remaining_time: 60,
+            layer_num: 50,
+            total_layers: 100,
+            filename: 'active.3mf',
+          });
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Active Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+
+      await waitFor(() => {
+        expect(
+          within(row as HTMLElement).getAllByText(/^ETA\s/),
+        ).toHaveLength(1);
+      });
+
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('does not show an ETA for a waiting item', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Waiting Print',
+              waiting_reason: 'Waiting for matching printer',
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Waiting Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('does not show an if-started-now ETA for a scheduled item', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Scheduled Print',
+              scheduled_time: new Date(
+                Date.now() + 5 * 60 * 60 * 1000,
+              ).toISOString(),
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Scheduled Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+    });
+
+    it('does not render a dangling ETA for an invalid duration', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            {
+              ...mockQueueItems[0],
+              archive_name: 'Invalid Duration Print',
+              print_time_seconds: -60,
+            },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Invalid Duration Print');
+      const row = name.closest('.group');
+
+      expect(row).not.toBeNull();
+      expect(
+        within(row as HTMLElement).queryByTestId('queue-item-eta'),
+      ).not.toBeInTheDocument();
+      expect(
+        within(row as HTMLElement).queryByText(/^ETA(?:\s|$)/),
+      ).not.toBeInTheDocument();
+    });
+
     it('shows completed items in history', async () => {
       const user = userEvent.setup();
       render(<QueuePage />);

+ 148 - 1
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -3,9 +3,14 @@
  */
 
 import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
 import { render } from '../utils';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { AuthProvider } from '../../contexts/AuthContext';
 import { SettingsPage } from '../../pages/SettingsPage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
@@ -1457,3 +1462,145 @@ describe('SettingsPage — sponsor banner audience', () => {
     expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
   });
 });
+
+describe('SettingsPage — settings changed outside the page (#2716)', () => {
+  const restoreLabel = 'Restore plate for finish photo';
+  // external_url is deliberately populated: when the server has none the page
+  // detects one from the browser and saves it unprompted, which would show up
+  // as a PUT in tests that assert none was made. That behaviour has its own
+  // test at the end of this block.
+  const baseSettings = { ...mockSettings, external_url: window.location.origin };
+
+  let queryClient: QueryClient;
+  let puts: Record<string, unknown>[];
+  let served: Record<string, unknown>;
+
+  function renderPage() {
+    queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
+    });
+    return rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
+                <SettingsPage />
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>
+    );
+  }
+
+  /** Change the settings row server-side and let the page's query observe it. */
+  async function changeOnServer(patch: Record<string, unknown>) {
+    served = { ...served, ...patch };
+    await act(async () => {
+      await queryClient.invalidateQueries({ queryKey: ['settings'] });
+    });
+  }
+
+  /** Wait out the 100ms initial-load suppression, then flip a checkbox. */
+  async function toggleRestorePlate() {
+    const label = await screen.findByText(restoreLabel);
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    const row = label.closest('div')!.parentElement!;
+    await userEvent.click(within(row).getByRole('checkbox'));
+  }
+
+  beforeEach(() => {
+    window.history.replaceState({}, '', '/');
+    localStorage.clear();
+    setAuthToken(null);
+    puts = [];
+    served = { ...baseSettings };
+
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(served)),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Record<string, unknown>;
+        puts.push(body);
+        served = { ...served, ...body };
+        return HttpResponse.json(served);
+      })
+    );
+  });
+
+  it('does not write its stale copy back over a server-side change', async () => {
+    // The defect: the page diffed the live query cache against its own copy, so
+    // a refetch that carried someone else's change read as a local edit and was
+    // reverted ~500ms later with no user interaction at all.
+    renderPage();
+    await screen.findByText(restoreLabel);
+    await new Promise((resolve) => setTimeout(resolve, 200));
+
+    await changeOnServer({ currency: 'EUR' });
+
+    // Well past the 500ms debounce.
+    await new Promise((resolve) => setTimeout(resolve, 1200));
+    expect(puts).toEqual([]);
+  });
+
+  it('adopts the server value, so a later save carries it rather than the stale one', async () => {
+    renderPage();
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    await changeOnServer({ currency: 'EUR' });
+
+    await toggleRestorePlate();
+
+    await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
+    // The user's edit is saved...
+    expect(puts[0].finish_photo_restore_plate).toBe(false);
+    // ...and the field they never touched goes back as the server's value, not
+    // the USD the page loaded with.
+    expect(puts[0].currency).toBe('EUR');
+  });
+
+  it('never reverts a pending user edit that the server changed too', async () => {
+    renderPage();
+    await toggleRestorePlate();
+    // Lands while the edit is still sitting in the 500ms debounce, i.e. before
+    // the page has committed it. Adopting the server's value here would throw
+    // the edit away silently.
+    await changeOnServer({ finish_photo_restore_plate: true });
+
+    await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
+    await new Promise((resolve) => setTimeout(resolve, 1200));
+    // Asserted over every request rather than a particular one: whichever order
+    // the refetch and the debounce happen to land in, no write may carry the
+    // server's value back over the user's.
+    expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
+  });
+
+  it('saves once per edit — the baseline moves with the saved row', async () => {
+    // Guards the failure mode the baseline introduces if it is not advanced on
+    // save: every render would diff against the pre-save snapshot and re-send.
+    renderPage();
+    await toggleRestorePlate();
+
+    await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
+    await new Promise((resolve) => setTimeout(resolve, 1500));
+    expect(puts).toHaveLength(1);
+  });
+
+  it('still persists the external_url it detects from the browser', async () => {
+    // The page seeds external_url from window.location.origin when the server
+    // has none and relies on the auto-save to persist it. That only works
+    // because the baseline is the raw server row: seed the baseline from the
+    // adjusted copy instead and the detected URL matches it, so nothing ever
+    // marks it as needing a save.
+    served = { ...mockSettings };
+    renderPage();
+    await screen.findByText(restoreLabel);
+    await new Promise((resolve) => setTimeout(resolve, 200));
+
+    // A refetch carrying a field this page does not manage. It is enough to
+    // re-run the diff, and the only thing that differs is the detected URL.
+    await changeOnServer({ spoolman_url: 'http://spoolman.example' });
+
+    await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
+    expect(puts[0].external_url).toBe(window.location.origin);
+  });
+});

+ 17 - 0
frontend/src/__tests__/utils/date.test.ts

@@ -339,6 +339,23 @@ describe('formatETA', () => {
     const result = formatETA(60 * 48); // 48 hours from now
     expect(result).not.toContain('Tomorrow');
   });
+
+  it('counts from baseTime when one is supplied', () => {
+    const base = new Date('2025-06-15T12:00:00Z').getTime();
+    // Same offset, two different starting instants: the results must differ by
+    // exactly the gap between those instants, not track the system clock.
+    const atNoon = formatETA(60, '24h', undefined, base);
+    const anHourLater = formatETA(60, '24h', undefined, base + 60 * 60 * 1000);
+
+    expect(atNoon).not.toBe(anHourLater);
+    expect(formatETA(60, '24h', undefined, base)).toBe(atNoon);
+    expect(formatETA(120, '24h', undefined, base)).toBe(anHourLater);
+  });
+
+  it('falls back to the system clock without baseTime', () => {
+    const explicit = formatETA(60, '24h', undefined, Date.now());
+    expect(formatETA(60, '24h')).toBe(explicit);
+  });
 });
 
 describe('formatDuration', () => {

+ 346 - 0
frontend/src/__tests__/utils/filamentPresets.test.ts

@@ -0,0 +1,346 @@
+import { describe, it, expect, vi } from 'vitest';
+import {
+  buildFilamentPresetOptions,
+  genericFilamentIdForMaterial,
+  presetDisplayName,
+  resolveFilamentId,
+} from '../../utils/filamentPresets';
+import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../../api/client';
+
+const localPreset = (over: Partial<LocalPreset> = {}): LocalPreset => ({
+  id: 1,
+  name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle',
+  preset_type: 'filament',
+  source: 'orca',
+  filament_type: 'PLA',
+  filament_vendor: 'Elegoo',
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  pressure_advance: null,
+  default_filament_colour: null,
+  filament_cost: null,
+  filament_density: null,
+  compatible_printers: null,
+  inherits: null,
+  version: null,
+  created_at: '',
+  updated_at: '',
+  ...over,
+});
+
+const orcaProfile = (over: Partial<OrcaProfileMeta> = {}): OrcaProfileMeta => ({
+  setting_id: 'a1b2c3',
+  name: 'Sunlu PETG',
+  type: 'filament',
+  version: null,
+  user_id: null,
+  updated_time: null,
+  is_custom: true,
+  ...over,
+});
+
+const cloudSetting = (over: Partial<SlicerSetting> = {}): SlicerSetting => ({
+  setting_id: 'GFSA00',
+  name: 'Bambu PLA Basic @BBL X1C',
+  type: 'filament',
+  version: null,
+  user_id: null,
+  updated_time: null,
+  is_custom: false,
+  ...over,
+});
+
+const builtin = (filament_id: string, name: string): BuiltinFilament => ({ filament_id, name });
+
+describe('genericFilamentIdForMaterial', () => {
+  it('maps an exact material', () => {
+    expect(genericFilamentIdForMaterial('PETG')).toBe('GFG99');
+  });
+
+  it('is case and whitespace tolerant', () => {
+    expect(genericFilamentIdForMaterial('  pla  ')).toBe('GFL99');
+  });
+
+  it('falls back to the base material when a suffix is unknown', () => {
+    // "PLA-GF" has no generic of its own; the PLA generic is the honest answer.
+    expect(genericFilamentIdForMaterial('PLA-GF')).toBe('GFL99');
+  });
+
+  it('returns empty rather than guessing for an unknown material', () => {
+    expect(genericFilamentIdForMaterial('UNOBTANIUM')).toBe('');
+    expect(genericFilamentIdForMaterial('')).toBe('');
+    expect(genericFilamentIdForMaterial(null)).toBe('');
+  });
+});
+
+describe('presetDisplayName', () => {
+  it('strips the printer/nozzle suffix', () => {
+    expect(presetDisplayName('Bambu PLA Basic @BBL X1C 0.4 nozzle')).toBe('Bambu PLA Basic');
+  });
+
+  it('strips the custom-preset marker', () => {
+    expect(presetDisplayName('# My PLA @BBL P1S')).toBe('My PLA');
+  });
+});
+
+describe('buildFilamentPresetOptions', () => {
+  it('is empty when every source is', () => {
+    expect(buildFilamentPresetOptions({})).toEqual([]);
+  });
+
+  it('ranks the tiers local > orca > cloud > builtin', () => {
+    const options = buildFilamentPresetOptions({
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
+      cloudSettings: [cloudSetting({ setting_id: 'GFSB99', name: 'Generic ABS' })],
+      orcaProfiles: [orcaProfile()],
+      localPresets: [localPreset()],
+    });
+    expect(options.map(o => o.source)).toEqual(['local', 'orca_cloud', 'cloud', 'builtin']);
+  });
+
+  it('sorts by name inside a tier', () => {
+    const options = buildFilamentPresetOptions({
+      builtinFilaments: [builtin('GFA01', 'Bambu PLA Matte'), builtin('GFA00', 'Bambu PLA Basic')],
+    });
+    expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
+  });
+
+  it('takes a builtin filament id straight from the table', () => {
+    const [option] = buildFilamentPresetOptions({ builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')] });
+    expect(option).toMatchObject({ id: 'builtin_GFA00', filamentId: 'GFA00' });
+  });
+
+  it('derives a Bambu official cloud preset id from its setting_id', () => {
+    const [option] = buildFilamentPresetOptions({ cloudSettings: [cloudSetting({ setting_id: 'GFSG98_09' })] });
+    expect(option.filamentId).toBe('GFG98');
+  });
+
+  it('leaves a cloud user preset unresolved for the detail lookup', () => {
+    // PFUS ids are setting ids, not filament ids — the printer rejects them,
+    // so guessing one here would file the calibration under nothing.
+    const [option] = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'PFUS9ac902733670a9', name: 'My PETG', is_custom: true })],
+    });
+    expect(option.filamentId).toBe('');
+  });
+
+  it('gives local and orca presets the generic id for their material', () => {
+    const options = buildFilamentPresetOptions({
+      localPresets: [localPreset({ filament_type: 'PETG' })],
+      orcaProfiles: [orcaProfile({ name: 'Sunlu ABS @BBL X1C' })],
+    });
+    expect(options.find(o => o.source === 'local')?.filamentId).toBe('GFG99');
+    expect(options.find(o => o.source === 'orca_cloud')?.filamentId).toBe('GFB99');
+  });
+
+  it('parses the material from the name when a local preset declares none', () => {
+    const [option] = buildFilamentPresetOptions({
+      localPresets: [localPreset({ filament_type: null, name: 'Overture TPU @BBL X1C' })],
+    });
+    expect(option.filamentId).toBe('GFU99');
+  });
+
+  it('collapses a cloud filament duplicated once per printer model', () => {
+    // Every Bambu Cloud account carries one copy per model. They share a
+    // filament id and, with the "@…" suffix stripped, one visible name.
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
+        cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL P1S' }),
+        cloudSetting({ setting_id: 'GFSA00_02', name: 'Bambu PLA Basic @BBL A1' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+    expect(options[0]).toMatchObject({ name: 'Bambu PLA Basic', filamentId: 'GFA00' });
+  });
+
+  it('collapses a cloud user preset duplicated per model, which has no filament id to key on', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'PFUSaaa', name: 'My PETG @BBL X1C', is_custom: true }),
+        cloudSetting({ setting_id: 'PFUSbbb', name: 'My PETG @BBL P1S', is_custom: true }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('keeps distinct cloud filaments apart', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [
+        cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
+        cloudSetting({ setting_id: 'GFSA01_00', name: 'Bambu PLA Matte @BBL X1C' }),
+      ],
+    });
+    expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
+  });
+
+  it('collapses one imported filament re-imported for several printers', () => {
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle' }),
+        localPreset({ id: 2, name: 'Elegoo PLA+ @BBL P1S 0.4 nozzle' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('keeps imported presets of different materials that share a generic id path', () => {
+    // Keyed by name, not by generic id — otherwise two distinct PLA imports
+    // would collapse into one because both map to GFL99.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+' }),
+        localPreset({ id: 2, name: 'Polymaker PolyLite PLA' }),
+      ],
+    });
+    expect(options).toHaveLength(2);
+  });
+
+  it('collapses Orca Cloud copies of one filament', () => {
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [
+        orcaProfile({ setting_id: 'u1', name: 'Sunlu PETG @BBL X1C' }),
+        orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL A1' }),
+      ],
+    });
+    expect(options).toHaveLength(1);
+  });
+
+  it('drops a builtin the cloud tier covers under a variant setting_id', () => {
+    // Cloud ids carry a "_NN" variant suffix; without normalising it the
+    // builtin tier lists the same filament a second time.
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL X1C' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
+  });
+
+  it('drops a builtin already offered by a cloud tier, matching the S-infix spelling', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'GFSA00', name: 'Bambu PLA Basic' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
+  });
+
+  it('drops a bambu cloud preset Orca Cloud already covers', () => {
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [orcaProfile({ setting_id: 'shared-id' })],
+      cloudSettings: [cloudSetting({ setting_id: 'shared-id' })],
+    });
+    expect(options.map(o => o.source)).toEqual(['orca_cloud']);
+  });
+
+  it('keeps an Orca Cloud library that overlaps an imported bundle by name', () => {
+    // These are usually the same profiles reached two ways. Letting the
+    // imported tier claim the name emptied the Orca Cloud group down to
+    // whatever happened not to be imported too.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C' }),
+        localPreset({ id: 2, name: 'Sunlu PETG @BBL X1C' }),
+      ],
+      orcaProfiles: [
+        orcaProfile({ setting_id: 'u1', name: 'Elegoo PLA+ @BBL X1C' }),
+        orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL X1C' }),
+      ],
+    });
+    expect(options.filter(o => o.source === 'local')).toHaveLength(2);
+    expect(options.filter(o => o.source === 'orca_cloud')).toHaveLength(2);
+  });
+
+  it('still drops a cross-tier row that carries an id a higher tier claimed', () => {
+    // A shared id is true identity, unlike a shared name.
+    const options = buildFilamentPresetOptions({
+      orcaProfiles: [orcaProfile({ setting_id: 'shared-id', name: 'Sunlu PETG' })],
+      cloudSettings: [cloudSetting({ setting_id: 'shared-id', name: 'Something Else' })],
+    });
+    expect(options.map(o => o.source)).toEqual(['orca_cloud']);
+  });
+
+  it('never echoes a filament the tiers above already offered back from the builtin table', () => {
+    // The builtin tier is a static copy of the same Bambu catalogue, so
+    // without a name check it re-listed everything under a fourth heading.
+    const options = buildFilamentPresetOptions({
+      localPresets: [localPreset({ name: 'Bambu PLA Basic @BBL X1C 0.4 nozzle' })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
+    });
+    expect(options.map(o => [o.source, o.name])).toEqual([
+      ['local', 'Bambu PLA Basic'],
+      ['builtin', 'Bambu PLA Matte'],
+    ]);
+  });
+
+  it('suppresses a builtin a cloud tier already named, even with no id overlap', () => {
+    const options = buildFilamentPresetOptions({
+      cloudSettings: [cloudSetting({ setting_id: 'PFUSaaa', name: 'Bambu PLA Basic @BBL X1C', is_custom: true })],
+      builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
+    });
+    expect(options.map(o => o.source)).toEqual(['cloud']);
+  });
+
+  it('does not let one import swallow every filament of the same material', () => {
+    // Imports resolve to a shared generic id (all PLA → GFL99); claiming that
+    // id would hide every other PLA behind the first one imported.
+    const options = buildFilamentPresetOptions({
+      localPresets: [
+        localPreset({ id: 1, name: 'Elegoo PLA+', filament_type: 'PLA' }),
+        localPreset({ id: 2, name: 'Polymaker PolyLite PLA', filament_type: 'PLA' }),
+      ],
+      builtinFilaments: [builtin('GFL99', 'Generic PLA')],
+    });
+    expect(options.map(o => o.name)).toEqual([
+      'Elegoo PLA+',
+      'Polymaker PolyLite PLA',
+      'Generic PLA',
+    ]);
+  });
+
+  it('strips printer suffixes from displayed names', () => {
+    const [option] = buildFilamentPresetOptions({ localPresets: [localPreset()] });
+    expect(option.name).toBe('Elegoo PLA+');
+  });
+});
+
+describe('resolveFilamentId', () => {
+  const option = (over = {}) => ({
+    id: 'PFUS9ac902733670a9',
+    name: 'My PETG',
+    source: 'cloud' as const,
+    filamentId: '',
+    filamentType: 'PETG',
+    ...over,
+  });
+
+  it('returns an already-known id without fetching', async () => {
+    const fetchDetail = vi.fn();
+    await expect(resolveFilamentId(option({ filamentId: 'GFA00' }), fetchDetail)).resolves.toBe('GFA00');
+    expect(fetchDetail).not.toHaveBeenCalled();
+  });
+
+  it('fetches the cloud detail for a user preset', async () => {
+    const fetchDetail = vi.fn().mockResolvedValue({ filament_id: 'P285e239' });
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('P285e239');
+    expect(fetchDetail).toHaveBeenCalledWith('PFUS9ac902733670a9');
+  });
+
+  it('returns empty when the detail carries no filament_id', async () => {
+    // Never fall back to base_id: that collapses a custom preset onto the
+    // generic it inherits from (#1053).
+    const fetchDetail = vi.fn().mockResolvedValue({ base_id: 'GFSG98_09' });
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
+  });
+
+  it('returns empty when the detail lookup fails', async () => {
+    const fetchDetail = vi.fn().mockRejectedValue(new Error('offline'));
+    await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
+  });
+
+  it('does not fetch for a non-cloud tier that resolved to nothing', async () => {
+    const fetchDetail = vi.fn();
+    const unknown = option({ source: 'local' as const, filamentType: 'UNOBTANIUM' });
+    await expect(resolveFilamentId(unknown, fetchDetail)).resolves.toBe('');
+    expect(fetchDetail).not.toHaveBeenCalled();
+  });
+});

+ 9 - 0
frontend/src/api/client.ts

@@ -357,6 +357,10 @@ export interface Printer {
   model: string | null;
   location: string | null;  // Group/location name
   nozzle_count: number;  // 1 or 2, auto-detected from MQTT
+  // Model is sold with both Standard and High Flow nozzles, so a K-profile's
+  // flow type is a real choice. Derived from the model, not the nozzle count —
+  // only the A-series has a single variant.
+  supports_nozzle_flow_type: boolean;
   is_active: boolean;
   auto_archive: boolean;
   external_camera_url: string | null;
@@ -3544,6 +3548,11 @@ export interface OIDCProvider {
   // #1589: when true, the LoginPage redirects unauthenticated visitors
   // straight to this provider on mount. At most one provider may carry this.
   is_autologin: boolean;
+  // #2593: defined by BAMBUDDY_OIDC_* and rewritten from the environment on
+  // every boot. The API answers 409 to any write, so the settings UI must not
+  // offer edit/delete controls that cannot succeed. Optional so a response
+  // from an older backend still type-checks.
+  is_env_managed?: boolean;
 }
 
 export interface OIDCProviderCreate {

+ 6 - 2
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -8,6 +8,7 @@ import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex
 import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 
 interface SlotInfo {
   amsId: number;
@@ -305,6 +306,9 @@ export function ConfigureAmsSlotModal({
   const [showSuccess, setShowSuccess] = useState(false);
   const [showExtendedColors, setShowExtendedColors] = useState(false);
   const scrolledToRef = useRef<string>('');
+  // The success state is held briefly before the modal closes itself; that
+  // timer must not outlive the modal.
+  const { schedule: scheduleClose } = useCancellableTimeout();
 
   // Fetch cloud settings (gracefully handle 401 when logged out)
   const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
@@ -614,7 +618,7 @@ export function ConfigureAmsSlotModal({
       setShowSuccess(true);
       onSuccess?.();
       // Close after showing success briefly
-      setTimeout(() => {
+      scheduleClose(() => {
         setShowSuccess(false);
         onClose();
       }, 1500);
@@ -629,7 +633,7 @@ export function ConfigureAmsSlotModal({
     onSuccess: () => {
       setShowSuccess(true);
       onSuccess?.();
-      setTimeout(() => {
+      scheduleClose(() => {
         setShowSuccess(false);
         onClose();
       }, 1500);

+ 272 - 116
frontend/src/components/KProfilesView.tsx

@@ -21,10 +21,17 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
+import {
+  buildFilamentPresetOptions,
+  resolveFilamentId,
+  type FilamentPresetOption,
+  type FilamentPresetSource,
+} from '../utils/filamentPresets';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 
 interface KProfileCardProps {
   profile: KProfile;
@@ -42,18 +49,26 @@ const truncateK = (value: string) => {
   return (Math.trunc(num * 1000) / 1000).toFixed(3);
 };
 
-// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
-const getFlowTypeLabel = (nozzleId: string) => {
-  if (nozzleId.startsWith('HH')) return 'HF';  // High Flow
-  return 'S';  // Standard Flow (default)
-};
-
-// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
+// nozzle_id encodes the flow type, per the slicer's own generator:
+//   "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
+// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
+// not a material code.
+const STANDARD_FLOW = 'HS00';
+const HIGH_FLOW = 'HH00';
+
+// Many printers omit nozzle_id from their extrusion_cali_get response entirely
+// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
+// Standard (its parser falls back to nvtStandard when the key is absent), and
+// so do we: the flow type stays a real, editable value rather than a blank.
 const getNozzleTypePrefix = (nozzleId: string) => {
   const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
-  return match ? match[1] : 'HH00';
+  return match ? match[1] : STANDARD_FLOW;
 };
 
+// Short label for the profile list.
+const getFlowTypeLabel = (nozzleId: string) =>
+  getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
+
 // Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
 const extractFilamentName = (profileName: string) => {
   // Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
@@ -149,9 +164,11 @@ interface KProfileModalProps {
   profile?: KProfile;
   printerId: number;
   nozzleDiameter: string;
-  existingProfiles?: KProfile[];  // Existing profiles for filament selection
+  existingProfiles?: KProfile[];  // Existing profiles, used for name resolution
   builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup
+  filamentPresets?: FilamentPresetOption[];  // Every filament this install knows, tiered
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
+  supportsFlowType?: boolean;  // Model sells both Standard and High Flow nozzles
   initialNote?: string;  // Initial note value for the profile
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
   onClose: () => void;
@@ -166,7 +183,9 @@ function KProfileModal({
   nozzleDiameter,
   existingProfiles = [],
   builtinFilaments = [],
+  filamentPresets = [],
   isDualNozzle = false,
+  supportsFlowType = true,
   initialNote = '',
   initialNoteKey = null,
   onClose,
@@ -181,10 +200,19 @@ function KProfileModal({
   const [kValue, setKValue] = useState(
     profile?.k_value ? truncateK(profile.k_value) : '0.020'
   );
-  const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
+  // What the Filament select is bound to. When editing, the printer's own
+  // filament_id (the select is read-only). For a new profile, the *preset
+  // handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
+  // setting_id or a builtin filament id — which is resolved to a real
+  // filament_id on submit, since only some tiers carry one directly.
+  const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
   // Split nozzle into type and diameter
+  // Both selects are read-only while editing: they report what the printer
+  // holds, they don't set it. '' means the printer reported no nozzle_id, which
+  // single-nozzle models never do (#1748) — showing "High Flow" there was the
+  // UI inventing a value the printer never sent.
   const [nozzleType, setNozzleType] = useState(
-    profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
+    profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
   );
   const [modalDiameter, setModalDiameter] = useState(
     profile?.nozzle_diameter || nozzleDiameter
@@ -197,40 +225,49 @@ function KProfileModal({
   const [isSyncing, setIsSyncing] = useState(false);
   const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
   const [note, setNote] = useState(initialNote);
-
-  // Extract unique filaments from existing K-profiles on the printer
-  // Use builtin filament table for accurate name resolution (filament_id → name)
-  // Falls back to extracting from profile name for custom/unknown presets
-  const knownFilaments = React.useMemo(() => {
-    // Build lookup map from builtin filament names (includes cloud presets from parent)
-    const builtinMap = new Map<string, string>();
-    for (const bf of builtinFilaments) {
-      builtinMap.set(bf.filament_id, bf.name);
-    }
-
-    const filamentMap = new Map<string, { id: string; name: string }>();
-    for (const p of existingProfiles) {
-      if (p.filament_id && !filamentMap.has(p.filament_id)) {
-        // Prefer builtin name (accurate), fall back to extracting from profile name
-        const builtinName = builtinMap.get(p.filament_id);
-        const filamentName = builtinName || extractFilamentName(p.name || '');
-        filamentMap.set(p.filament_id, {
-          id: p.filament_id,
-          name: filamentName || p.filament_id,
-        });
-      }
-    }
-    return Array.from(filamentMap.values()).sort((a, b) =>
-      a.name.localeCompare(b.name)
-    );
-  }, [existingProfiles, builtinFilaments]);
+  const [filamentQuery, setFilamentQuery] = useState('');
+  // The modal defers its own close so the printer has time to process the
+  // command; that timer must not outlive the modal.
+  const { schedule: scheduleClose } = useCancellableTimeout();
+
+  // Name for the filament an existing profile is bound to. The builtin table
+  // (which the parent has already merged with the user's cloud presets) is
+  // authoritative; a profile whose filament_id is in neither falls back to the
+  // name the printer stored for it.
+  const editedFilamentName = React.useMemo(() => {
+    if (!profile?.filament_id) return '';
+    const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
+    if (builtinName) return builtinName;
+    const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
+    return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
+  }, [profile, existingProfiles, builtinFilaments]);
+
+  // The tiered list, grouped for rendering. Order is fixed app-wide —
+  // imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
+  // and buildFilamentPresetOptions has already sorted by it, so grouping is
+  // just a partition that preserves that order.
+  const presetGroups = React.useMemo(() => {
+    const labels: [FilamentPresetSource, string][] = [
+      ['local', t('kProfiles.modal.source.local')],
+      ['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
+      ['cloud', t('kProfiles.modal.source.bambuCloud')],
+      ['builtin', t('kProfiles.modal.source.builtin')],
+    ];
+    const query = filamentQuery.trim().toLowerCase();
+    const matches = query
+      ? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
+      : filamentPresets;
+    return labels
+      .map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
+      .filter(g => g.items.length > 0);
+  }, [filamentPresets, filamentQuery, t]);
 
   const saveMutation = useMutation({
     mutationFn: (data: KProfileCreate) => {
       console.log('[KProfile] Calling API...');
       return api.setKProfile(printerId, data);
     },
-    onSuccess: (result) => {
+    onSuccess: (result, variables) => {
       console.log('[KProfile] Save success:', result);
       showToast(t('kProfiles.toast.profileSaved'));
       // Save note if it changed (including clearing it)
@@ -243,8 +280,10 @@ function KProfileModal({
           // Editing: use setting_id if available, or composite key with slot_id
           profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
         } else {
-          // New profile: use name as key (will be matched when profile is loaded)
-          profileKey = `name_${name}_${filamentId}`;
+          // New profile: use name as key (matched against the reloaded profile,
+          // so it has to be the resolved filament_id that was sent — not the
+          // preset handle the user picked).
+          profileKey = `name_${name}_${variables.filament_id}`;
         }
         onSaveNote(profileKey, note);
       }
@@ -252,7 +291,7 @@ function KProfileModal({
       setIsSyncing(true);
       // Add delay before closing to give printer time to process the save
       // onSave will trigger refetch in the parent component
-      setTimeout(() => {
+      scheduleClose(() => {
         setIsSyncing(false);
         onSave();
       }, 2500);
@@ -276,7 +315,7 @@ function KProfileModal({
       setIsSyncing(true);
       // Add longer delay for delete - printer needs more time to process
       // before it can return the updated profile list
-      setTimeout(() => {
+      scheduleClose(() => {
         setIsSyncing(false);
         onClose();
       }, 4000);
@@ -316,14 +355,46 @@ function KProfileModal({
     // Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
     const nozzleId = `${nozzleType}-${modalDiameter}`;
 
+    // An edit is delete + re-add on single-nozzle printers, so the nozzle
+    // fields have to survive the round trip — both selects are disabled while
+    // editing. Rebuilding them blindly from the selects is what let a 0.6mm
+    // profile come back as "HH00-0.4" once the parse defaults had stamped it
+    // 0.4 (#1748), so prefer whatever the printer reported. Where it reported
+    // no nozzle_id at all, send the rebuilt one rather than an empty string —
+    // the field is part of the profile's identity on the wire and the slicer
+    // always populates it.
+    const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
+    const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
+
+    // The printer indexes its calibration table by filament_id, so the preset
+    // the user picked has to be reduced to one before anything is sent. Only
+    // the builtin tier and Bambu's official cloud presets carry one outright;
+    // a cloud *user* preset needs its detail fetched, and imported / Orca
+    // presets have no Bambu id at all and map to the generic for their
+    // material. Refuse rather than guess when nothing resolves — a profile
+    // filed under the wrong filament is invisible to the slot that needs it.
+    let resolvedFilamentId = profile?.filament_id || '';
+    if (!profile) {
+      const picked = filamentPresets.find(p => p.id === filamentChoice);
+      if (!picked) {
+        showToast(t('kProfiles.toast.selectFilament'), 'error');
+        return;
+      }
+      resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
+      if (!resolvedFilamentId) {
+        showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
+        return;
+      }
+    }
+
     // For editing or single extruder: just save one profile
     if (profile || selectedExtruders.length === 1) {
       const payload = {
         name: name,
         k_value: formattedKValue,
-        filament_id: filamentId,
-        nozzle_id: nozzleId,
-        nozzle_diameter: modalDiameter,
+        filament_id: resolvedFilamentId,
+        nozzle_id: editNozzleId,
+        nozzle_diameter: editDiameter,
         extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
         setting_id: profile?.setting_id,
         slot_id: profile?.slot_id ?? 0,
@@ -341,7 +412,7 @@ function KProfileModal({
     const batchPayload = selectedExtruders.map(extruderId => ({
       name: name,
       k_value: formattedKValue,
-      filament_id: filamentId,
+      filament_id: resolvedFilamentId,
       nozzle_id: nozzleId,
       nozzle_diameter: modalDiameter,
       extruder_id: extruderId,
@@ -356,7 +427,7 @@ function KProfileModal({
       showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
       // Save note for new batch profiles
       if (onSaveNote && note) {
-        const profileKey = `name_${name}_${filamentId}`;
+        const profileKey = `name_${name}_${resolvedFilamentId}`;
         onSaveNote(profileKey, note);
       }
     } catch (error) {
@@ -370,7 +441,7 @@ function KProfileModal({
     setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
     // Wait for final sync before closing
     // onSave will trigger refetch in the parent component
-    setTimeout(() => {
+    scheduleClose(() => {
       setIsSyncing(false);
       setSavingProgress({ current: 0, total: 0 });
       onSave();
@@ -454,49 +525,77 @@ function KProfileModal({
             {/* Filament - read-only when editing */}
             <div>
               <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
-              <select
-                value={filamentId}
-                onChange={(e) => {
-                  const newFilamentId = e.target.value;
-                  setFilamentId(newFilamentId);
-                  // Auto-generate profile name when filament is selected (for new profiles)
-                  // Only auto-generate if name is empty - don't overwrite user input
-                  if (!profile && newFilamentId && !name) {
-                    const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
-                    if (selectedFilament) {
-                      const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
-                      setName(`${flowLabel} ${selectedFilament.name}`);
-                    }
-                  }
-                }}
-                disabled={!!profile}
-                className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
-                required={!profile}
-              >
-                <option value="">{t('kProfiles.modal.selectFilament')}</option>
-                {/* Show current filament when editing - look up from knownFilaments */}
-                {profile?.filament_id && (
-                  <option key={profile.filament_id} value={profile.filament_id}>
-                    {knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
-                  </option>
-                )}
-                {/* Show known filaments from existing K-profiles (for new profiles) */}
-                {!profile && knownFilaments.map((f) => (
-                  <option key={f.id} value={f.id}>
-                    {f.name}
-                  </option>
-                ))}
-              </select>
-              {!profile && knownFilaments.length === 0 && (
-                <p className="text-xs text-bambu-gray mt-1">
-                  {t('kProfiles.modal.noFilamentsHelp')}
-                </p>
+              {profile ? (
+                // Editing or copying: the filament is fixed, so this is a
+                // readout rather than a control.
+                <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
+                  {editedFilamentName || profile.filament_id}
+                </div>
+              ) : (
+                // A real list rather than a <select>: Chrome ignores almost
+                // every CSS property on <optgroup>, so a source heading inside
+                // a native dropdown can't be made to stand out.
+                <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+                  <div className="relative border-b border-bambu-dark-tertiary">
+                    <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                    <input
+                      type="text"
+                      value={filamentQuery}
+                      onChange={(e) => setFilamentQuery(e.target.value)}
+                      placeholder={t('kProfiles.modal.searchFilaments')}
+                      className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
+                    />
+                  </div>
+                  <div className="max-h-56 overflow-y-auto bg-bambu-dark">
+                    {presetGroups.length === 0 ? (
+                      <p className="px-3 py-3 text-xs text-bambu-gray">
+                        {filamentPresets.length === 0
+                          ? t('kProfiles.modal.noFilamentsHelp')
+                          : t('kProfiles.modal.noFilamentMatches')}
+                      </p>
+                    ) : presetGroups.map((group) => (
+                      <div key={group.source}>
+                        <div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
+                          <span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
+                            {group.label}
+                          </span>
+                          <span className="text-[10px] text-bambu-gray">{group.items.length}</span>
+                        </div>
+                        {group.items.map((f) => (
+                          <button
+                            key={f.id}
+                            type="button"
+                            onClick={() => {
+                              setFilamentChoice(f.id);
+                              // Auto-generate the profile name, but never over
+                              // an entry the user typed.
+                              if (!name) {
+                                const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
+                                setName(`${flowLabel} ${f.name}`);
+                              }
+                            }}
+                            className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
+                              filamentChoice === f.id
+                                ? 'bg-bambu-green/20 text-white'
+                                : 'text-white hover:bg-bambu-dark-tertiary'
+                            }`}
+                          >
+                            {f.name}
+                          </button>
+                        ))}
+                      </div>
+                    ))}
+                  </div>
+                </div>
               )}
             </div>
 
-            {/* Flow Type and Nozzle Size - read-only when editing */}
-            <div className="grid grid-cols-2 gap-4">
-              <div>
+            {/* Flow Type and Nozzle Size - read-only when editing. Flow type
+                is hidden on models sold with a single nozzle variant (the
+                A-series), where the choice would be meaningless — same gate
+                the slicer applies via support_nozzle_volume(). */}
+            <div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
+              <div className={supportsFlowType ? '' : 'hidden'}>
                 <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
                 <select
                   value={nozzleType}
@@ -505,10 +604,10 @@ function KProfileModal({
                     setNozzleType(newNozzleType);
                     // Update profile name when flow type changes (for new profiles)
                     // Only auto-generate if name is empty - don't overwrite user input
-                    if (!profile && filamentId && !name) {
-                      const selectedFilament = knownFilaments.find(f => f.id === filamentId);
+                    if (!profile && filamentChoice && !name) {
+                      const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
                       if (selectedFilament) {
-                        const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
+                        const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
                         setName(`${flowLabel} ${selectedFilament.name}`);
                       }
                     }
@@ -516,8 +615,8 @@ function KProfileModal({
                   disabled={!!profile}
                   className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 >
-                  <option value="HH00">{t('kProfiles.modal.highFlow')}</option>
-                  <option value="HS00">{t('kProfiles.modal.standard')}</option>
+                  <option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
+                  <option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
                 </select>
               </div>
               <div>
@@ -772,13 +871,12 @@ export function KProfilesView() {
     refetchOnMount: 'always',  // Always refetch when component mounts
   });
 
-  // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
-  const { data: allProfiles } = useQuery({
-    queryKey: ['kprofiles', selectedPrinter, '0.4'],
-    queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
-    enabled: !!selectedPrinter,
-    staleTime: 60000,  // Cache for 1 minute
-  });
+  // A second fetch for 0.4mm profiles used to seed the Add-Profile filament
+  // dropdown. The dropdown is built from the filament preset tiers now
+  // (#2719), so the round trip bought nothing — and it fired concurrently
+  // with the fetch above whenever a different nozzle was selected, which is
+  // exactly the two-requests-in-flight case that made K-profile fetches time
+  // out (#1748).
 
   // Fetch builtin filament names for accurate filament_id → name resolution
   const { data: builtinFilaments } = useQuery({
@@ -794,6 +892,28 @@ export function KProfilesView() {
     staleTime: 300000,  // Cache for 5 minutes
   });
 
+  // The other three filament tiers, so a printer with no K-profiles yet can
+  // still be given its first one (#2719). Each query stands alone and fails
+  // quietly: not being signed in to a cloud should thin the list, not break
+  // the page, and the builtin tier above guarantees it is never empty.
+  const { data: localPresets } = useQuery({
+    queryKey: ['localPresets'],
+    queryFn: () => api.getLocalPresets(),
+    retry: false,
+  });
+
+  const { data: orcaCloudList } = useQuery({
+    queryKey: ['orcaCloudProfilesForKProfiles'],
+    queryFn: () => api.orcaCloudListProfiles(),
+    retry: false,
+  });
+
+  const { data: cloudSettings } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+    retry: false,
+  });
+
   // Fetch K-profile notes (stored locally)
   const {
     data: notesData,
@@ -860,6 +980,19 @@ export function KProfilesView() {
     }));
   }, [builtinFilamentMap]);
 
+  // Every filament this install knows about, ranked in the app-wide order:
+  // imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
+  // built-in table as the floor.
+  const filamentPresets = React.useMemo(
+    () => buildFilamentPresetOptions({
+      localPresets: localPresets?.filament,
+      orcaProfiles: orcaCloudList?.filament,
+      cloudSettings: cloudSettings?.filament,
+      builtinFilaments,
+    }),
+    [localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
+  );
+
   // Resolve filament name: builtin table first, then extract from profile name
   const resolveFilamentName = React.useCallback((profile: KProfile) => {
     return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
@@ -911,6 +1044,18 @@ export function KProfilesView() {
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
   const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
 
+  // Whether this printer model is sold with both Standard and High Flow
+  // nozzles. Comes from the model, not from whether the payload happened to
+  // carry a nozzle_id — most printers omit that field entirely (#1748) while
+  // still offering both flows. Only the A-series has a single variant.
+  const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
+
+  // Don't strand the list behind a filter whose control just disappeared.
+  useEffect(() => {
+    if (!supportsFlowType) setFlowTypeFilter('all');
+  }, [supportsFlowType]);
+
+
   // Keyboard shortcuts
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -1002,7 +1147,10 @@ export function KProfilesView() {
               name: p.name,
               k_value: parseFloat(p.k_value).toFixed(6),
               filament_id: p.filament_id,
-              nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
+              // An export from a printer that reports no nozzle_id carries
+              // none; fall back to Standard, the same default the slicer's
+              // parser uses for a missing field.
+              nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
               nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
               extruder_id: p.extruder_id ?? 0,
               slot_id: 0, // Always create new
@@ -1248,17 +1396,19 @@ export function KProfilesView() {
             </select>
           </div>
         )}
-        <div className="w-32">
-          <select
-            value={flowTypeFilter}
-            onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
-            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-          >
-            <option value="all">{t('kProfiles.allFlow')}</option>
-            <option value="hf">{t('kProfiles.hfOnly')}</option>
-            <option value="s">{t('kProfiles.sOnly')}</option>
-          </select>
-        </div>
+        {supportsFlowType && (
+          <div className="w-32">
+            <select
+              value={flowTypeFilter}
+              onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            >
+              <option value="all">{t('kProfiles.allFlow')}</option>
+              <option value="hf">{t('kProfiles.hfOnly')}</option>
+              <option value="s">{t('kProfiles.sOnly')}</option>
+            </select>
+          </div>
+        )}
         <div className="w-32">
           <select
             value={sortOption}
@@ -1451,9 +1601,11 @@ export function KProfilesView() {
             profile={editingProfile}
             printerId={selectedPrinter}
             nozzleDiameter={nozzleDiameter}
-            existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+            existingProfiles={kprofiles?.profiles}
             builtinFilaments={enrichedBuiltinFilaments}
+            filamentPresets={filamentPresets}
             isDualNozzle={isDualNozzle}
+            supportsFlowType={supportsFlowType}
             initialNote={note}
             initialNoteKey={key}
             onSaveNote={handleSaveNote}
@@ -1476,9 +1628,11 @@ export function KProfilesView() {
         <KProfileModal
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          existingProfiles={kprofiles?.profiles}
           builtinFilaments={enrichedBuiltinFilaments}
+          filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
+          supportsFlowType={supportsFlowType}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
           onClose={() => {
@@ -1497,9 +1651,11 @@ export function KProfilesView() {
         <KProfileModal
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
-          existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          existingProfiles={kprofiles?.profiles}
           builtinFilaments={enrichedBuiltinFilaments}
+          filamentPresets={filamentPresets}
           isDualNozzle={isDualNozzle}
+          supportsFlowType={supportsFlowType}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
           // Pass profile data but without slot_id to create a new profile

+ 58 - 39
frontend/src/components/OIDCProviderSettings.tsx

@@ -1,6 +1,6 @@
 import { useState, type ReactNode } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react';
+import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff, Lock } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
@@ -386,6 +386,11 @@ export function OIDCProviderSettings() {
                       <X className="w-3 h-3" /> {t('common.disabled')}
                     </span>
                   )}
+                  {provider.is_env_managed && (
+                    <span className="flex items-center gap-1 text-xs text-bambu-green">
+                      <Lock className="w-3 h-3" /> {t('settings.environmentManagedLabel')}
+                    </span>
+                  )}
                 </div>
                 <div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
                   <ExternalLink className="w-3 h-3" />
@@ -393,45 +398,59 @@ export function OIDCProviderSettings() {
                 </div>
               </div>
               <div className="flex items-center gap-2">
-                {provider.icon_url && (
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => refreshIconMutation.mutate(provider.id)}
-                    disabled={refreshIconMutation.isPending}
-                    title={t('settings.oidc.refreshIcon')}
-                    data-testid={`refresh-icon-${provider.id}`}
-                  >
-                    <RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
-                  </Button>
+                {/* #2593: startup rewrites the env-managed row from BAMBUDDY_OIDC_*
+                    and the API answers 409, so offering any of these would promise
+                    a change that cannot land -- the icon routes included, where the
+                    click only ever produced an error toast. */}
+                {!provider.is_env_managed && (
+                  <>
+                    {provider.icon_url && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => refreshIconMutation.mutate(provider.id)}
+                        disabled={refreshIconMutation.isPending}
+                        title={t('settings.oidc.refreshIcon')}
+                        data-testid={`refresh-icon-${provider.id}`}
+                      >
+                        <RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
+                      </Button>
+                    )}
+                    {provider.has_icon && (
+                      <Button
+                        variant="secondary"
+                        size="sm"
+                        onClick={() => removeIconMutation.mutate(provider.id)}
+                        disabled={removeIconMutation.isPending}
+                        title={t('settings.oidc.removeIcon')}
+                        data-testid={`remove-icon-${provider.id}`}
+                      >
+                        <ImageOff className="w-4 h-4" />
+                      </Button>
+                    )}
+                    <Toggle
+                      checked={provider.is_enabled}
+                      onChange={() => toggleEnabled(provider)}
+                      disabled={updateMutation.isPending}
+                    />
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
+                      data-testid={`edit-provider-${provider.id}`}
+                    >
+                      <Edit2 className="w-4 h-4" />
+                    </Button>
+                    <Button
+                      variant="danger"
+                      size="sm"
+                      onClick={() => setDeleteTarget(provider)}
+                      data-testid={`delete-provider-${provider.id}`}
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </Button>
+                  </>
                 )}
-                {provider.has_icon && (
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => removeIconMutation.mutate(provider.id)}
-                    disabled={removeIconMutation.isPending}
-                    title={t('settings.oidc.removeIcon')}
-                    data-testid={`remove-icon-${provider.id}`}
-                  >
-                    <ImageOff className="w-4 h-4" />
-                  </Button>
-                )}
-                <Toggle
-                  checked={provider.is_enabled}
-                  onChange={() => toggleEnabled(provider)}
-                  disabled={updateMutation.isPending}
-                />
-                <Button
-                  variant="secondary"
-                  size="sm"
-                  onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
-                >
-                  <Edit2 className="w-4 h-4" />
-                </Button>
-                <Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
-                  <Trash2 className="w-4 h-4" />
-                </Button>
               </div>
             </div>
           </CardHeader>

+ 37 - 0
frontend/src/hooks/useCancellableTimeout.ts

@@ -0,0 +1,37 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+/**
+ * setTimeout that cannot outlive the component that scheduled it.
+ *
+ * Modals here defer their own close by a second or more so the printer has
+ * time to process the command that was just sent. A plain setTimeout for that
+ * keeps a reference to setState and to the parent's onClose, and fires whether
+ * or not the modal is still mounted — closing an already-dismissed dialog, or
+ * throwing outright once the surrounding environment is gone ("window is not
+ * defined" when a test's DOM is torn down before the timer fires).
+ *
+ * Returns a schedule function. Scheduling again replaces any pending timer, and
+ * unmounting cancels it.
+ */
+export function useCancellableTimeout() {
+  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const cancel = useCallback(() => {
+    if (timer.current !== null) {
+      clearTimeout(timer.current);
+      timer.current = null;
+    }
+  }, []);
+
+  const schedule = useCallback((fn: () => void, ms: number) => {
+    cancel();
+    timer.current = setTimeout(() => {
+      timer.current = null;
+      fn();
+    }, ms);
+  }, [cancel]);
+
+  useEffect(() => cancel, [cancel]);
+
+  return { schedule, cancel };
+}

+ 12 - 1
frontend/src/i18n/locales/de.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Fertigstellungszeit, wenn dieser Auftrag jetzt starten würde',
       asap: 'Sofort',
       overdue: 'Überfällig',
       now: 'Jetzt',
@@ -5034,7 +5035,15 @@ export default {
       kValueHelp: 'Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG',
       filament: 'Filament',
       selectFilament: 'Filament auswählen...',
-      noFilamentsHelp: 'Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.',
+      source: {
+        local: 'Importiert',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integriert',
+      },
+      noFilamentsHelp: 'Keine Filamente verfügbar. Melde dich bei Bambu Cloud an oder importiere Presets unter Profile → Lokale Profile.',
+      searchFilaments: 'Filamente durchsuchen...',
+      noFilamentMatches: 'Kein Filament passt zu dieser Suche',
       flowType: 'Flusstyp',
       highFlow: 'Hoher Durchfluss',
       standard: 'Standard',
@@ -5067,6 +5076,8 @@ export default {
       profileSaved: 'K-Profil gespeichert',
       profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',
       selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',
+      selectFilament: 'Bitte zuerst ein Filament auswählen',
+      filamentNotResolvable: 'Keine Bambu-Filament-ID für {{name}} — der Drucker kann dafür kein Profil speichern',
       profileDeleted: 'K-Profil gelöscht',
       profilesDeleted: '{{count}} Profile gelöscht',
       exportedProfiles: '{{count}} Profile exportiert',

+ 12 - 1
frontend/src/i18n/locales/en.ts

@@ -1308,6 +1308,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Completion time if this job started now',
       asap: 'ASAP',
       overdue: 'Overdue',
       now: 'Now',
@@ -5078,7 +5079,15 @@ export default {
       kValueHelp: 'Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG',
       filament: 'Filament',
       selectFilament: 'Select filament...',
-      noFilamentsHelp: 'No filaments found. Create a K-profile in Bambu Studio first.',
+      source: {
+        local: 'Imported',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Built-in',
+      },
+      noFilamentsHelp: 'No filaments available. Sign in to Bambu Cloud, or import presets under Profiles → Local Profiles.',
+      searchFilaments: 'Search filaments...',
+      noFilamentMatches: 'No filament matches that search',
       flowType: 'Flow Type',
       highFlow: 'High Flow',
       standard: 'Standard',
@@ -5111,6 +5120,8 @@ export default {
       profileSaved: 'K-profile saved',
       profilesSaved: 'K-profile saved to {{count}} extruders',
       selectAtLeastOneExtruder: 'Please select at least one extruder',
+      selectFilament: 'Select a filament first',
+      filamentNotResolvable: 'No Bambu filament ID for {{name}} — the printer cannot store a profile for it',
       profileDeleted: 'K-profile deleted',
       profilesDeleted: 'Deleted {{count}} profiles',
       exportedProfiles: 'Exported {{count}} profiles',

+ 12 - 1
frontend/src/i18n/locales/es.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Hora de finalización si este trabajo comenzara ahora',
       asap: 'Lo antes posible',
       overdue: 'Atrasada',
       now: 'Ahora',
@@ -5043,7 +5044,15 @@ export default {
       kValueHelp: 'Rango típico: 0,01 - 0,06 para PLA, 0,02 - 0,10 para PETG',
       filament: 'Filamento',
       selectFilament: 'Seleccionar filamento...',
-      noFilamentsHelp: 'No se encontraron filamentos. Cree primero un perfil K en Bambu Studio.',
+      source: {
+        local: 'Importado',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrado',
+      },
+      noFilamentsHelp: 'No hay filamentos disponibles. Inicia sesión en Bambu Cloud o importa ajustes en Perfiles → Perfiles locales.',
+      searchFilaments: 'Buscar filamentos...',
+      noFilamentMatches: 'Ningún filamento coincide con esa búsqueda',
       flowType: 'Tipo de flujo',
       highFlow: 'Flujo alto',
       standard: 'Estándar',
@@ -5076,6 +5085,8 @@ export default {
       profileSaved: 'Perfil K guardado',
       profilesSaved: 'Perfil K guardado en {{count}} extrusores',
       selectAtLeastOneExtruder: 'Seleccione al menos un extrusor',
+      selectFilament: 'Selecciona primero un filamento',
+      filamentNotResolvable: 'No hay ID de filamento Bambu para {{name}}: la impresora no puede guardar un perfil',
       profileDeleted: 'Perfil K eliminado',
       profilesDeleted: 'Se eliminaron {{count}} perfiles',
       exportedProfiles: 'Se exportaron {{count}} perfiles',

+ 12 - 1
frontend/src/i18n/locales/fr.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Heure de fin si cette tâche démarrait maintenant',
       asap: 'Dès que possible',
       overdue: 'En retard',
       now: 'Maintenant',
@@ -5024,7 +5025,15 @@ export default {
       kValueHelp: 'Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)',
       filament: 'Filament',
       selectFilament: 'Choisir filament...',
-      noFilamentsHelp: 'Créez d\'abord un profil dans Bambu Studio.',
+      source: {
+        local: 'Importé',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Inclus',
+      },
+      noFilamentsHelp: 'Aucun filament disponible. Connectez-vous à Bambu Cloud ou importez des préréglages dans Profils → Profils locaux.',
+      searchFilaments: 'Rechercher des filaments...',
+      noFilamentMatches: 'Aucun filament ne correspond à cette recherche',
       flowType: 'Type de débit',
       highFlow: 'Haut Débit (HF)',
       standard: 'Standard',
@@ -5057,6 +5066,8 @@ export default {
       profileSaved: 'Profil K enregistré',
       profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',
       selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',
+      selectFilament: 'Sélectionnez d’abord un filament',
+      filamentNotResolvable: 'Aucun identifiant de filament Bambu pour {{name}} — l’imprimante ne peut pas enregistrer de profil',
       profileDeleted: 'Profil K supprimé',
       profilesDeleted: '{{count}} profils supprimés',
       exportedProfiles: '{{count}} profils exportés',

+ 12 - 1
frontend/src/i18n/locales/it.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Orario di completamento se questo lavoro iniziasse ora',
       asap: 'ASAP',
       overdue: 'Scaduto',
       now: 'Ora',
@@ -5023,7 +5024,15 @@ export default {
       kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',
       filament: 'Filamento',
       selectFilament: 'Seleziona filamento...',
-      noFilamentsHelp: 'Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.',
+      source: {
+        local: 'Importato',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrato',
+      },
+      noFilamentsHelp: 'Nessun filamento disponibile. Accedi a Bambu Cloud o importa i preset da Profili → Profili locali.',
+      searchFilaments: 'Cerca filamenti...',
+      noFilamentMatches: 'Nessun filamento corrisponde alla ricerca',
       flowType: 'Tipo flow',
       highFlow: 'Alto flusso',
       standard: 'Standard',
@@ -5056,6 +5065,8 @@ export default {
       profileSaved: 'K-profile salvato',
       profilesSaved: 'K-profile salvato su {{count}} estrusori',
       selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',
+      selectFilament: 'Seleziona prima un filamento',
+      filamentNotResolvable: 'Nessun ID filamento Bambu per {{name}}: la stampante non può salvare un profilo',
       profileDeleted: 'K-profile eliminato',
       profilesDeleted: 'Eliminati {{count}} profili',
       exportedProfiles: 'Esportati {{count}} profili',

+ 12 - 1
frontend/src/i18n/locales/ja.ts

@@ -1292,6 +1292,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'このジョブを今開始した場合の完了予定時刻',
       asap: '即時',
       overdue: '期限超過',
       now: '今すぐ',
@@ -5035,7 +5036,15 @@ export default {
       kValueHelp: '一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10',
       filament: 'フィラメント',
       selectFilament: 'フィラメントを選択...',
-      noFilamentsHelp: 'フィラメントが見つかりません。Bambu Studioでまずプロファイルを作成してください。',
+      source: {
+        local: 'インポート済み',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '内蔵',
+      },
+      noFilamentsHelp: '利用できるフィラメントがありません。Bambu Cloud にログインするか、プロファイル → ローカルプロファイル でプリセットをインポートしてください。',
+      searchFilaments: 'フィラメントを検索...',
+      noFilamentMatches: '検索に一致するフィラメントはありません',
       flowType: 'フロータイプ',
       highFlow: 'ハイフロー',
       standard: 'スタンダード',
@@ -5068,6 +5077,8 @@ export default {
       profileSaved: 'Kプロファイルを保存しました',
       profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',
       selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',
+      selectFilament: '先にフィラメントを選択してください',
+      filamentNotResolvable: '{{name}} に対応する Bambu フィラメント ID がないため、プリンターはプロファイルを保存できません',
       profileDeleted: 'Kプロファイルを削除しました',
       profilesDeleted: '{{count}}件のプロファイルを削除しました',
       exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',

+ 12 - 1
frontend/src/i18n/locales/ko.ts

@@ -1223,6 +1223,7 @@ export default {
       description: '아카이브 페이지의 컨텍스트 메뉴에서 "예약" 옵션을 사용하거나 파일을 드래그 앤 드롭하여 시작하세요.'
     },
     time: {
+      etaIfStartedNow: '이 작업을 지금 시작할 경우의 완료 예정 시각',
       asap: '즉시',
       overdue: '기한 초과',
       now: '지금',
@@ -4778,7 +4779,15 @@ export default {
       kValueHelp: '일반 범위: PLA 0.01~0.06, PETG 0.02~0.10',
       filament: '필라멘트',
       selectFilament: '필라멘트 선택...',
-      noFilamentsHelp: '필라멘트를 찾을 수 없습니다. 먼저 Bambu Studio에서 K-프로필을 만드세요.',
+      source: {
+        local: '가져온 것',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '기본 제공',
+      },
+      noFilamentsHelp: '사용할 수 있는 필라먼트가 없습니다. Bambu Cloud에 로그인하거나 프로파일 → 로컬 프로파일에서 프리셋을 가져오세요.',
+      searchFilaments: '필라먼트 검색...',
+      noFilamentMatches: '검색과 일치하는 필라먼트가 없습니다',
       flowType: '유량 유형',
       highFlow: '고유량',
       standard: '표준',
@@ -4808,6 +4817,8 @@ export default {
       profileSaved: 'K-프로필 저장됨',
       profilesSaved: '{{count}}개 압출기에 K-프로필 저장됨',
       selectAtLeastOneExtruder: '적어도 하나의 압출기를 선택해 주세요',
+      selectFilament: '먼저 필라먼트를 선택하세요',
+      filamentNotResolvable: '{{name}}에 해당하는 Bambu 필라먼트 ID가 없어 프린터가 프로파일을 저장할 수 없습니다',
       profileDeleted: 'K-프로필 삭제됨',
       profilesDeleted: '{{count}}개 프로필 삭제됨',
       exportedProfiles: '{{count}}개 프로필 내보냄',

+ 12 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: 'Horário de conclusão se este trabalho começasse agora',
       asap: 'ASAP',
       overdue: 'Atrasado',
       now: 'Agora',
@@ -5023,7 +5024,15 @@ export default {
       kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
       filament: 'Filamento',
       selectFilament: 'Selecionar filamento...',
-      noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',
+      source: {
+        local: 'Importado',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Integrado',
+      },
+      noFilamentsHelp: 'Nenhum filamento disponível. Entre na Bambu Cloud ou importe predefinições em Perfis → Perfis Locais.',
+      searchFilaments: 'Buscar filamentos...',
+      noFilamentMatches: 'Nenhum filamento corresponde a essa busca',
       flowType: 'Tipo de Fluxo',
       highFlow: 'Alto Fluxo',
       standard: 'Padrão',
@@ -5056,6 +5065,8 @@ export default {
       profileSaved: 'K-profile salvo',
       profilesSaved: 'K-profile salvo em {{count}} extrusores',
       selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
+      selectFilament: 'Selecione um filamento primeiro',
+      filamentNotResolvable: 'Sem ID de filamento Bambu para {{name}} — a impressora não consegue armazenar um perfil',
       profileDeleted: 'K-profile excluído',
       profilesDeleted: '{{count}} perfis excluídos',
       exportedProfiles: '{{count}} perfis exportados',

+ 12 - 1
frontend/src/i18n/locales/ru.ts

@@ -1233,6 +1233,7 @@ export default {
       description: "Запланируйте печать на странице архива через пункт «Запланировать» в контекстном меню либо перетащите сюда файлы.",
     },
     time: {
+      etaIfStartedNow: "Время завершения, если запустить это задание сейчас",
       asap: "Как можно скорее",
       overdue: "Просрочено",
       now: "Сейчас",
@@ -4766,7 +4767,15 @@ export default {
       kValueHelp: "Типичный диапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       filament: "Филамент",
       selectFilament: "Выберите филамент...",
-      noFilamentsHelp: "Филаменты не найдены. Сначала создайте K-профиль в Bambu Studio.",
+      source: {
+        local: "Импортированные",
+        orcaCloud: "Orca Cloud",
+        bambuCloud: "Bambu Cloud",
+        builtin: "Встроенный",
+      },
+      noFilamentsHelp: "Нет доступных филаментов. Войдите в Bambu Cloud или импортируйте пресеты в разделе Профили → Локальные профили.",
+      searchFilaments: "Поиск филаментов...",
+      noFilamentMatches: "Нет филаментов, соответствующих запросу",
       flowType: "Тип потока",
       highFlow: "Высокопоточный",
       standard: "Стандартный",
@@ -4796,6 +4805,8 @@ export default {
       profileSaved: "K-профиль сохранён",
       profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
       selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
+      selectFilament: "Сначала выберите филамент",
+      filamentNotResolvable: "Нет идентификатора филамента Bambu для {{name}} — принтер не сможет сохранить профиль",
       profileDeleted: "K-профиль удалён",
       profilesDeleted: "Удалено профилей: {{count}}",
       exportedProfiles: "Экспортировано профилей: {{count}}",

+ 12 - 1
frontend/src/i18n/locales/tr.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Zaman
     time: {
+      etaIfStartedNow: 'Bu iş şimdi başlatılırsa tamamlanma saati',
       asap: 'ASAP',
       overdue: 'Gecikmiş',
       now: 'Şimdi',
@@ -5003,7 +5004,15 @@ export default {
       kValueHelp: 'Tipik aralık: PLA için 0.01 - 0.06, PETG için 0.02 - 0.10',
       filament: 'Filament',
       selectFilament: 'Filament seç...',
-      noFilamentsHelp: 'Filament bulunamadı. Önce Bambu Studio\'da bir K-profili oluşturun.',
+      source: {
+        local: 'İçe aktarılmış',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: 'Yerleşik',
+      },
+      noFilamentsHelp: 'Kullanılabilir filament yok. Bambu Cloud’a giriş yapın veya Profiller → Yerel Profiller altından hızır ayarları içe aktarın.',
+      searchFilaments: 'Filament ara...',
+      noFilamentMatches: 'Bu aramayla eşleşen filament yok',
       flowType: 'Akış Türü',
       highFlow: 'Yüksek Akış',
       standard: 'Standart',
@@ -5033,6 +5042,8 @@ export default {
       profileSaved: 'K-profili kaydedildi',
       profilesSaved: '{{count}} ekstrüdere K-profili kaydedildi',
       selectAtLeastOneExtruder: 'Lütfen en az bir ekstrüder seçin',
+      selectFilament: 'Önce bir filament seçin',
+      filamentNotResolvable: '{{name}} için Bambu filament kimliği yok — yazıcı bunun için profil saklayamaz',
       profileDeleted: 'K-profili silindi',
       profilesDeleted: '{{count}} profil silindi',
       exportedProfiles: '{{count}} profil dışa aktarıldı',

+ 12 - 1
frontend/src/i18n/locales/uk.ts

@@ -1308,6 +1308,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: "Час завершення, якщо запустити це завдання зараз",
       asap: "Якнайшвидше",
       overdue: "Прострочено",
       now: "Зараз",
@@ -5078,7 +5079,15 @@ export default {
       kValueHelp: "Типовий діапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
       filament: "Філамент",
       selectFilament: "Виберіть філамент...",
-      noFilamentsHelp: "Філаменти не знайдено. Спочатку створіть K-профіль у Bambu Studio.",
+      source: {
+        local: "Імпортовані",
+        orcaCloud: "Orca Cloud",
+        bambuCloud: "Bambu Cloud",
+        builtin: "Вбудований",
+      },
+      noFilamentsHelp: "Немає доступних філаментів. Увійдіть у Bambu Cloud або імпортуйте пресети в розділі Профілі → Локальні профілі.",
+      searchFilaments: "Пошук філаментів...",
+      noFilamentMatches: "Немає філаментів, що відповідають запиту",
       flowType: "Тип потоку",
       highFlow: "Сопло з високим потоком",
       standard: "Стандартний",
@@ -5111,6 +5120,8 @@ export default {
       profileSaved: "K-профіль збережено",
       profilesSaved: "K-профіль збережено в екструдери {{count}}.",
       selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
+      selectFilament: "Спочатку виберіть філамент",
+      filamentNotResolvable: "Немає ідентифікатора філаменту Bambu для {{name}} — принтер не зможе зберегти профіль",
       profileDeleted: "K-профіль видалено",
       profilesDeleted: "Видалені профілі {{count}}.",
       exportedProfiles: "Експортовані профілі {{count}}.",

+ 12 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: '若此任务现在开始的预计完成时间',
       asap: '尽快',
       overdue: '已逾期',
       now: '现在',
@@ -5023,7 +5024,15 @@ export default {
       kValueHelp: '典型范围:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       filament: '耗材',
       selectFilament: '选择耗材...',
-      noFilamentsHelp: '未找到耗材。请先在 Bambu Studio 中创建 K 值配置。',
+      source: {
+        local: '已导入',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '内置',
+      },
+      noFilamentsHelp: '没有可用的耗材。请登录 Bambu Cloud,或在“配置 → 本地配置”中导入预设。',
+      searchFilaments: '搜索耗材...',
+      noFilamentMatches: '没有符合搜索条件的耗材',
       flowType: '流量类型',
       highFlow: '高流量',
       standard: '标准',
@@ -5056,6 +5065,8 @@ export default {
       profileSaved: 'K 值配置已保存',
       profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
       selectAtLeastOneExtruder: '请至少选择一个挤出机',
+      selectFilament: '请先选择耗材',
+      filamentNotResolvable: '没有与 {{name}} 对应的 Bambu 耗材 ID,打印机无法保存该配置',
       profileDeleted: 'K 值配置已删除',
       profilesDeleted: '已删除 {{count}} 个配置',
       exportedProfiles: '已导出 {{count}} 个配置',

+ 12 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -1293,6 +1293,7 @@ export default {
     },
     // Time
     time: {
+      etaIfStartedNow: '若此工作現在開始的預計完成時間',
       asap: '儘快',
       overdue: '已逾期',
       now: '現在',
@@ -5023,7 +5024,15 @@ export default {
       kValueHelp: '典型範圍:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
       filament: '耗材',
       selectFilament: '選擇耗材...',
-      noFilamentsHelp: '未找到耗材。請先在 Bambu Studio 中建立 K 值設定。',
+      source: {
+        local: '已匯入',
+        orcaCloud: 'Orca Cloud',
+        bambuCloud: 'Bambu Cloud',
+        builtin: '內建',
+      },
+      noFilamentsHelp: '沒有可用的耗材。請登入 Bambu Cloud,或在「設定檔 → 本地設定檔」中匯入預設。',
+      searchFilaments: '搜尋耗材...',
+      noFilamentMatches: '沒有符合搜尋條件的耗材',
       flowType: '流量類型',
       highFlow: '高流量',
       standard: '標準',
@@ -5056,6 +5065,8 @@ export default {
       profileSaved: 'K 值設定已儲存',
       profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',
       selectAtLeastOneExtruder: '請至少選擇一個擠出機',
+      selectFilament: '請先選擇耗材',
+      filamentNotResolvable: '沒有與 {{name}} 對應的 Bambu 耗材 ID,印表機無法儲存該設定檔',
       profileDeleted: 'K 值設定已刪除',
       profilesDeleted: '已刪除 {{count}} 個設定',
       exportedProfiles: '已匯出 {{count}} 個設定',

+ 142 - 0
frontend/src/pages/QueuePage.tsx

@@ -356,6 +356,8 @@ function SortableQueueItem({
   hasPermission,
   canModify,
   printerState,
+  showEta = false,
+  etaNow,
   t,
 }: {
   item: PrintQueueItem;
@@ -377,6 +379,11 @@ function SortableQueueItem({
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
   printerState?: string | null;
+  // Whether this item qualifies for an "if started now" ETA (#2740), and the
+  // instant to measure it from. Both are decided by the page so every row on
+  // screen quotes the same clock.
+  showEta?: boolean;
+  etaNow?: number;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   // Fetch printer status every 30 seconds while printing to monitor progress
@@ -428,6 +435,16 @@ function SortableQueueItem({
   const isPending = item.status === 'pending';
   const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
 
+  // This is an "if started now" estimate, not a cumulative queue forecast, so
+  // it is only shown for items the page determined could actually start now
+  // (see etaEligibleIds). etaNow is the caller's ticking clock — deriving the
+  // ETA from it rather than from Date.now() keeps this render deterministic and
+  // stops the value freezing at first paint.
+  const queueItemEta =
+    isPending && showEta && item.print_time_seconds != null && item.print_time_seconds > 0
+      ? formatETA(item.print_time_seconds / 60, timeFormat, t, etaNow)
+      : null;
+
   const isMobileSelectable = isPending && onToggleSelect;
 
   return (
@@ -603,6 +620,15 @@ function SortableQueueItem({
                 {formatDuration(item.print_time_seconds)}
               </span>
             )}
+            {queueItemEta && (
+              <span
+                data-testid="queue-item-eta"
+                className="text-bambu-green font-medium"
+                title={t('queue.time.etaIfStartedNow')}
+              >
+                ETA {queueItemEta}
+              </span>
+            )}
             {item.filament_used_grams && (
               <span className="flex items-center gap-1 sm:gap-1.5">
                 <Weight className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
@@ -857,6 +883,10 @@ interface QueueRowRenderProps {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   canModify: (resource: any, action: any, createdById?: number | null) => boolean;
   t: (key: string, options?: Record<string, unknown>) => string;
+  // Items that qualify for an "if started now" ETA, and the shared clock it is
+  // measured from (#2740).
+  etaEligibleIds: Set<number>;
+  etaNow: number;
   aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number };
   // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row
   // (single item or batch) one step among its siblings; onMoveBlock is the
@@ -882,6 +912,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
     hasPermission,
     canModify,
     t,
+    etaEligibleIds,
+    etaNow,
     onMoveUp,
     onMoveDown,
   } = props;
@@ -903,6 +935,8 @@ function QueueRowRender(props: QueueRowRenderProps) {
         onToggleSelect={() => handleToggleSelect(row.item.id)}
         hasPermission={hasPermission}
         canModify={canModify}
+        showEta={etaEligibleIds.has(row.item.id)}
+        etaNow={etaNow}
         t={t}
       />
     );
@@ -928,6 +962,8 @@ function SortableBatchRow({
   hasPermission,
   canModify,
   t,
+  etaEligibleIds,
+  etaNow,
   aggregateForRows,
   onMoveUp,
   onMoveDown,
@@ -1119,6 +1155,8 @@ function SortableBatchRow({
               onToggleSelect={() => handleToggleSelect(child.id)}
               hasPermission={hasPermission}
               canModify={canModify}
+              showEta={etaEligibleIds.has(child.id)}
+              etaNow={etaNow}
               t={t}
             />
           ))}
@@ -1759,6 +1797,106 @@ export function QueuePage() {
     return items;
   }, [queue, filterLocation, matchesLocationFilter]);
 
+  // Queue items eligible for an "if started now" ETA (#2740).
+  //
+  // The ETA answers "when would this finish if it began right now", so it may
+  // only appear on items that really could begin right now. Deriving that from
+  // waiting_reason alone is not enough: the scheduler only writes that field on
+  // the model-based assignment path (print_scheduler.py), so an item pinned to a
+  // specific printer sits behind a running job with waiting_reason still NULL.
+  //
+  // Computed from the unfiltered queue on purpose — hiding a printer behind the
+  // location filter must not make its printer look free.
+  const etaEligibleIds = useMemo(() => {
+    const eligible = new Set<number>();
+    if (!queue) return eligible;
+
+    const busyPrinters = new Set<number>();
+    queue.forEach(item => {
+      if (item.status === 'printing' && item.printer_id) busyPrinters.add(item.printer_id);
+    });
+
+    const isFutureScheduled = (item: PrintQueueItem): boolean => {
+      if (!item.scheduled_time) return false;
+      return (parseUTCDate(item.scheduled_time)?.getTime() ?? 0) > Date.now();
+    };
+
+    // Mirrors the scheduler's own ordering so "next up" here means the item the
+    // scheduler would actually dispatch next, not whatever the user sorted by.
+    const schedulerOrder = (a: PrintQueueItem, b: PrintQueueItem): number => {
+      if (settings?.queue_shortest_first) {
+        const aJumped = a.been_jumped ? 1 : 0;
+        const bJumped = b.been_jumped ? 1 : 0;
+        if (aJumped !== bJumped) return bJumped - aJumped;
+        const aTime = a.print_time_seconds ?? Infinity;
+        const bTime = b.print_time_seconds ?? Infinity;
+        if (aTime !== bTime) return aTime - bTime;
+      }
+      return a.position - b.position;
+    };
+
+    // Claimants for each printer, in the order the scheduler would take them.
+    // Staged and future-scheduled items are excluded: the scheduler skips both
+    // without marking the printer busy, so neither holds up the item behind it.
+    const contenders = new Map<number, PrintQueueItem[]>();
+    queue
+      .filter(
+        item =>
+          item.status === 'pending' &&
+          item.printer_id != null &&
+          !item.manual_start &&
+          !isFutureScheduled(item)
+      )
+      .sort(schedulerOrder)
+      .forEach(item => {
+        const list = contenders.get(item.printer_id!) ?? [];
+        list.push(item);
+        contenders.set(item.printer_id!, list);
+      });
+
+    queue.forEach(item => {
+      if (item.status !== 'pending') return;
+      // Blocked, scheduled for later, or no usable duration to add.
+      if (item.waiting_reason) return;
+      if (isFutureScheduled(item)) return;
+      if (item.print_time_seconds == null || item.print_time_seconds <= 0) return;
+      // Conditional on an earlier print's outcome, which the UI cannot see: the
+      // scheduler may skip it outright rather than ever running it.
+      if (item.require_previous_success) return;
+
+      // Model-based items have no printer yet; an empty waiting_reason is the
+      // scheduler saying it found one, so trust that.
+      if (item.printer_id == null) {
+        eligible.add(item.id);
+        return;
+      }
+
+      if (busyPrinters.has(item.printer_id)) return;
+      // Staged items wait on the user, not on the queue, so they are startable
+      // whenever their printer is free regardless of what is queued ahead.
+      if (item.manual_start) {
+        eligible.add(item.id);
+        return;
+      }
+      if (contenders.get(item.printer_id)?.[0]?.id === item.id) eligible.add(item.id);
+    });
+
+    return eligible;
+  }, [queue, settings?.queue_shortest_first]);
+
+  // The ETA is "now + duration", so it goes stale on its own. Nothing else
+  // re-renders these rows while the queue payload is unchanged (react-query's
+  // structural sharing keeps the reference stable), so drive it from a clock of
+  // our own. Only runs while an ETA is actually on screen.
+  const [etaNow, setEtaNow] = useState(() => Date.now());
+  const hasEtas = etaEligibleIds.size > 0;
+  useEffect(() => {
+    if (!hasEtas) return;
+    setEtaNow(Date.now());
+    const id = setInterval(() => setEtaNow(Date.now()), 30000);
+    return () => clearInterval(id);
+  }, [hasEtas]);
+
   // Get unique printer IDs from active items to fetch their statuses
   const activePrinterIds = useMemo(() => {
     const ids = new Set<number>();
@@ -2547,6 +2685,8 @@ export function QueuePage() {
                           hasPermission={hasPermission}
                           canModify={canModify}
                           t={t}
+                          etaEligibleIds={etaEligibleIds}
+                          etaNow={etaNow}
                           aggregateForRows={aggregateForRows}
                           {...rowMovers(groupedRows, idx)}
                           onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
@@ -2585,6 +2725,8 @@ export function QueuePage() {
                                   hasPermission={hasPermission}
                                   canModify={canModify}
                                   t={t}
+                                  etaEligibleIds={etaEligibleIds}
+                                  etaNow={etaNow}
                                   aggregateForRows={aggregateForRows}
                                   {...rowMovers(bucket.rows, idx)}
                                   onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}

+ 120 - 78
frontend/src/pages/SettingsPage.tsx

@@ -882,6 +882,16 @@ export function SettingsPage() {
   const pendingGcodeSnippetsRef = useRef<string | null>(null);
   const isSavingRef = useRef(false);
   const isInitialLoadRef = useRef(true);
+  // #2716: the last server snapshot this page reconciled with. It is what
+  // makes "the user edited this field" a well-defined question: a field where
+  // localSettings still equals the baseline has not been touched since that
+  // reconcile, so a newer server value can be taken instead of the page's stale
+  // copy being written back over it. Before this the debounced save diffed
+  // against the live ['settings'] cache, which made a value changed on the
+  // server -- another tab, another user, a backup restore, a refetch driven by
+  // any of the ~30 other observers of the key -- indistinguishable from an edit,
+  // and reverted it a few hundred ms later with no user interaction at all.
+  const serverBaselineRef = useRef<AppSettings | null>(null);
 
   // Sync local state when settings load
   useEffect(() => {
@@ -891,6 +901,9 @@ export function SettingsPage() {
         ...settings,
         external_url: settings.external_url || window.location.origin,
       };
+      // The baseline is the raw server row, not this adjusted copy: a detected
+      // external_url has to read as a local change so it still gets persisted.
+      serverBaselineRef.current = settings;
       setLocalSettings(settingsWithExternalUrl);
       // Mark initial load complete after a short delay
       setTimeout(() => {
@@ -899,9 +912,37 @@ export function SettingsPage() {
     }
   }, [settings, localSettings]);
 
+  // #2716: reconcile a moved server snapshot into the local copy. A field the
+  // user has not touched since the last reconcile takes the server's value; a
+  // field they have edited keeps theirs and is saved over it by the debounced
+  // effect below, so the newer of the two writes wins either way. Declared
+  // before that effect so the baseline has already moved by the time it
+  // computes its diff in the same commit.
+  useEffect(() => {
+    const baseline = serverBaselineRef.current;
+    if (!settings || !localSettings || !baseline || settings === baseline) {
+      return;
+    }
+    const adopted: Record<string, unknown> = {};
+    for (const key of Object.keys(settings) as (keyof AppSettings)[]) {
+      if (settings[key] !== baseline[key] && localSettings[key] === baseline[key]) {
+        adopted[key] = settings[key];
+      }
+    }
+    serverBaselineRef.current = settings;
+    if (Object.keys(adopted).length > 0) {
+      setLocalSettings(prev => (prev ? { ...prev, ...(adopted as Partial<AppSettings>) } : prev));
+    }
+  }, [settings, localSettings]);
+
   const updateMutation = useMutation({
     mutationFn: api.updateSettings,
     onSuccess: (data) => {
+      // #2716: the row we just saved becomes the snapshot to diff against.
+      // The setQueryData below would normally get the effect above to do this,
+      // but only if react-query hands back a new object; setting it here means
+      // the baseline never lags behind a save regardless.
+      serverBaselineRef.current = data;
       queryClient.setQueryData(['settings'], data);
       // Don't call setLocalSettings(data) here — it would overwrite in-progress
       // user input (e.g. typing a hostname) with the stale saved snapshot,
@@ -942,7 +983,8 @@ export function SettingsPage() {
   // Debounced auto-save when localSettings change
   useEffect(() => {
     // Skip if initial load or no settings
-    if (isInitialLoadRef.current || !localSettings || !settings) {
+    const baseline = serverBaselineRef.current;
+    if (isInitialLoadRef.current || !localSettings || !settings || !baseline) {
       return;
     }
 
@@ -956,83 +998,83 @@ export function SettingsPage() {
 
     // Check if there are actual changes
     const hasChanges =
-      settings.auto_archive !== localSettings.auto_archive ||
-      settings.save_thumbnails !== localSettings.save_thumbnails ||
-      settings.capture_finish_photo !== localSettings.capture_finish_photo ||
-      (settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
-      settings.default_filament_cost !== localSettings.default_filament_cost ||
-      settings.currency !== localSettings.currency ||
-      settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
-      settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
-      settings.check_updates !== localSettings.check_updates ||
-      (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
-      (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
-      (settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
-      settings.notification_language !== localSettings.notification_language ||
-      (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
-      settings.ams_humidity_good !== localSettings.ams_humidity_good ||
-      settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
-      settings.ams_temp_good !== localSettings.ams_temp_good ||
-      settings.ams_temp_fair !== localSettings.ams_temp_fair ||
-      settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
-      settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
-      settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
-      (settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
-      (settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
-      (settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
-      (settings.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
-      (settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
-      (settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
-      settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
-      settings.date_format !== localSettings.date_format ||
-      settings.time_format !== localSettings.time_format ||
-      settings.default_printer_id !== localSettings.default_printer_id ||
-      settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
-      settings.ftp_retry_count !== localSettings.ftp_retry_count ||
-      settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
-      settings.ftp_timeout !== localSettings.ftp_timeout ||
-      settings.mqtt_enabled !== localSettings.mqtt_enabled ||
-      settings.mqtt_broker !== localSettings.mqtt_broker ||
-      settings.mqtt_port !== localSettings.mqtt_port ||
-      settings.mqtt_username !== localSettings.mqtt_username ||
-      settings.mqtt_password !== localSettings.mqtt_password ||
-      settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
-      settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
-      settings.external_url !== localSettings.external_url ||
-      settings.ha_enabled !== localSettings.ha_enabled ||
-      settings.ha_url !== localSettings.ha_url ||
-      settings.ha_token !== localSettings.ha_token ||
-      (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
-      Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
-      (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
-      (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
-      (settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
-      (settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
-      (settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
-      (settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
-      (settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
-      settings.prometheus_enabled !== localSettings.prometheus_enabled ||
-      settings.prometheus_token !== localSettings.prometheus_token ||
-      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
-      (settings.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
-      (settings.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
-      (settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
-      (settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
-      (settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
-      (settings.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
-      (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
-      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
-      (settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
-      (settings.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
-      (settings.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
-      (settings.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
-      (settings.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
-      (settings.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
-      (settings.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
-      (settings.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
-      (settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
-      (settings.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
-      (settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
+      baseline.auto_archive !== localSettings.auto_archive ||
+      baseline.save_thumbnails !== localSettings.save_thumbnails ||
+      baseline.capture_finish_photo !== localSettings.capture_finish_photo ||
+      (baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
+      baseline.default_filament_cost !== localSettings.default_filament_cost ||
+      baseline.currency !== localSettings.currency ||
+      baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
+      baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
+      baseline.check_updates !== localSettings.check_updates ||
+      (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
+      (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
+      (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
+      baseline.notification_language !== localSettings.notification_language ||
+      (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
+      baseline.ams_humidity_good !== localSettings.ams_humidity_good ||
+      baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
+      baseline.ams_temp_good !== localSettings.ams_temp_good ||
+      baseline.ams_temp_fair !== localSettings.ams_temp_fair ||
+      baseline.ams_history_retention_days !== localSettings.ams_history_retention_days ||
+      baseline.disable_filament_warnings !== localSettings.disable_filament_warnings ||
+      baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
+      (baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
+      (baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
+      (baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
+      (baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
+      (baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
+      (baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
+      baseline.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
+      baseline.date_format !== localSettings.date_format ||
+      baseline.time_format !== localSettings.time_format ||
+      baseline.default_printer_id !== localSettings.default_printer_id ||
+      baseline.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
+      baseline.ftp_retry_count !== localSettings.ftp_retry_count ||
+      baseline.ftp_retry_delay !== localSettings.ftp_retry_delay ||
+      baseline.ftp_timeout !== localSettings.ftp_timeout ||
+      baseline.mqtt_enabled !== localSettings.mqtt_enabled ||
+      baseline.mqtt_broker !== localSettings.mqtt_broker ||
+      baseline.mqtt_port !== localSettings.mqtt_port ||
+      baseline.mqtt_username !== localSettings.mqtt_username ||
+      baseline.mqtt_password !== localSettings.mqtt_password ||
+      baseline.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
+      baseline.mqtt_use_tls !== localSettings.mqtt_use_tls ||
+      baseline.external_url !== localSettings.external_url ||
+      baseline.ha_enabled !== localSettings.ha_enabled ||
+      baseline.ha_url !== localSettings.ha_url ||
+      baseline.ha_token !== localSettings.ha_token ||
+      (baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
+      Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
+      (baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
+      (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
+      (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
+      (baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
+      (baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
+      (baseline.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
+      (baseline.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
+      baseline.prometheus_enabled !== localSettings.prometheus_enabled ||
+      baseline.prometheus_token !== localSettings.prometheus_token ||
+      (baseline.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
+      (baseline.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
+      (baseline.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
+      (baseline.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
+      (baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
+      (baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
+      (baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
+      (baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
+      (baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
+      (baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
+      (baseline.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
+      (baseline.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
+      (baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
+      (baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
+      (baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
+      (baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
+      (baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
+      (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
+      (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
+      (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
 
     if (!hasChanges) {
       return;

+ 8 - 2
frontend/src/utils/date.ts

@@ -319,14 +319,20 @@ export function formatTimeOnly(
  * @param remainingMinutes - Minutes until completion
  * @param timeFormat - Time format setting ('system', '12h', '24h')
  * @param t - Optional i18n translation function
+ * @param baseTime - Instant to count from, in epoch ms. Defaults to the current
+ *   clock. Callers that render an ETA for something not yet started must pass a
+ *   value that changes over time, or the string freezes at first render: it is
+ *   only recomputed when the component re-renders, which does not happen while
+ *   the underlying data is unchanged (#2740).
  * @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
  */
 export function formatETA(
   remainingMinutes: number,
   timeFormat: TimeFormat = 'system',
-  t?: (key: string) => string
+  t?: (key: string) => string,
+  baseTime?: number
 ): string {
-  const now = new Date();
+  const now = baseTime != null ? new Date(baseTime) : new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
 
   const today = new Date(now);

+ 247 - 0
frontend/src/utils/filamentPresets.ts

@@ -0,0 +1,247 @@
+// Tiered filament-preset list, shared by every picker that has to offer "all
+// the filaments this install knows about".
+//
+// Lookup order is fixed across the app: local imported > Orca Cloud > Bambu
+// Cloud > hardcoded built-in table. It mirrors ConfigureAmsSlotModal's picker
+// and SliceModal's tier groups, so a filament the user sees in one place is
+// named and ranked the same way in the others.
+//
+// The built-in table is the floor, not an equal source: it is a static list
+// compiled into the backend, so it is the only tier that can never be empty
+// and the only one that works with no cloud account and nothing imported.
+
+import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../api/client';
+import { parsePresetName, toFilamentId } from '../components/spool-form/utils';
+
+export type FilamentPresetSource = 'local' | 'orca_cloud' | 'cloud' | 'builtin';
+
+export interface FilamentPresetOption {
+  /** Opaque, source-prefixed handle: ``local_12`` / ``orca_<uuid>`` / a Bambu
+   *  cloud setting_id / ``builtin_GFA00``. Prefixes match the convention
+   *  ConfigureAmsSlotModal already uses so the two can share resolvers. */
+  id: string;
+  name: string;
+  source: FilamentPresetSource;
+  /** The Bambu filament id this preset resolves to, when it is derivable
+   *  without a network round trip. Empty for Bambu Cloud *user* presets, whose
+   *  real filament_id only exists in the cloud detail — see
+   *  resolveFilamentId. */
+  filamentId: string;
+  /** Material as the preset itself declares it, used to derive a generic
+   *  filament id for tiers that carry no Bambu id of their own. */
+  filamentType: string;
+}
+
+export interface FilamentPresetSources {
+  localPresets?: LocalPreset[];
+  orcaProfiles?: OrcaProfileMeta[];
+  cloudSettings?: SlicerSetting[];
+  builtinFilaments?: BuiltinFilament[];
+}
+
+/** Generic Bambu filament ids by material. Local and Orca Cloud presets carry
+ *  no Bambu filament id, but the printer's calibration table is indexed by
+ *  one, so the closest generic is what a calibration for such a preset has to
+ *  be filed under. Same table and same fallback chain as the AMS slot
+ *  configure flow — the two must agree or a profile created here won't match
+ *  the slot configured there. */
+const GENERIC_FILAMENT_IDS: Record<string, string> = {
+  'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
+  'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
+  'ABS': 'GFB99', 'ASA': 'GFB98',
+  'PC': 'GFC99',
+  'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
+  'TPU': 'GFU99',
+  'PVA': 'GFS99', 'HIPS': 'GFS98',
+  'PE': 'GFP99', 'PP': 'GFP97',
+};
+
+/** Resolve a material string to a generic Bambu filament id, trying the exact
+ *  spelling before progressively stripping the suffixes slicer presets add
+ *  ("-CF", "+", " HF"). Returns '' when nothing matches, which callers must
+ *  treat as "not calibratable" rather than substituting a default — filing a
+ *  calibration under the wrong material is worse than refusing. */
+export function genericFilamentIdForMaterial(material: string | null | undefined): string {
+  const m = (material || '').toUpperCase().trim();
+  if (!m) return '';
+  return GENERIC_FILAMENT_IDS[m]
+    || GENERIC_FILAMENT_IDS[m.replace(/[-\s]?CF$/, '')]
+    || GENERIC_FILAMENT_IDS[m.replace(/\+$/, '')]
+    || GENERIC_FILAMENT_IDS[m.split(/[-\s]/)[0]]
+    || '';
+}
+
+/** Strip the printer/nozzle suffix and the "# " custom-preset marker a preset
+ *  name may carry, e.g. "Elegoo PLA+ @BBL X1C 0.4 nozzle" → "Elegoo PLA+". */
+export function presetDisplayName(name: string): string {
+  const withoutSuffix = name.replace(/@.+$/, '').trim();
+  return withoutSuffix.startsWith('# ') ? withoutSuffix.slice(2).trim() : withoutSuffix;
+}
+
+const SOURCE_ORDER: Record<FilamentPresetSource, number> = {
+  local: 0,
+  orca_cloud: 1,
+  cloud: 2,
+  builtin: 3,
+};
+
+/**
+ * Merge every filament source into one ranked list.
+ *
+ * Deduplication is deliberately asymmetric, because "the same name in two
+ * tiers" means different things depending on which tiers:
+ *
+ *  - *Within* a tier, by resolved filament id or display name. This is what
+ *    collapses the per-printer-model copies a cloud account carries —
+ *    "Bambu PLA Basic @BBL X1C", "@BBL P1S", "@BBL A1" are one name once the
+ *    suffix is stripped — and repeated imports of one filament for several
+ *    printers.
+ *
+ *  - *Across* tiers, by id only. Two entries carrying the same id really are
+ *    one record reached by two routes; two entries merely sharing a name are
+ *    not. Imported presets and an Orca Cloud library overlap heavily by name
+ *    (they are usually the same profiles, synced), and suppressing one for the
+ *    other empties a tier the user curated on purpose. The heading says where
+ *    each came from, which is the point of having tiers at all.
+ *
+ *  - *Into the built-in tier*, by name as well as by id. That tier is a static
+ *    table of the same Bambu catalogue every other source also ships, so
+ *    without a name check it echoes back everything above it. It exists to
+ *    guarantee the list is never empty, not to be a fourth copy.
+ */
+export function buildFilamentPresetOptions(sources: FilamentPresetSources): FilamentPresetOption[] {
+  const { localPresets, orcaProfiles, cloudSettings, builtinFilaments } = sources;
+  const options: FilamentPresetOption[] = [];
+
+  const nameKey = (name: string) => name.trim().toLowerCase();
+
+  // Ids seen anywhere: a cloud setting_id, an Orca profile id, a resolved
+  // filament id. Shared across tiers — an id collision is true identity.
+  const claimedIds = new Set<string>();
+  // Names seen, scoped to one tier, so two tiers can each list "Elegoo PLA+".
+  const namesInTier = new Set<string>();
+  // Every name any real source offered, consulted only by the built-in tier.
+  const namesOffered = new Set<string>();
+
+  const take = (source: FilamentPresetSource, name: string, ...ids: (string | undefined)[]): boolean => {
+    const usableIds = ids.filter((k): k is string => !!k);
+    if (usableIds.some(k => claimedIds.has(k))) return false;
+    const scoped = `${source}|${nameKey(name)}`;
+    if (namesInTier.has(scoped)) return false;
+    usableIds.forEach(k => claimedIds.add(k));
+    namesInTier.add(scoped);
+    namesOffered.add(nameKey(name));
+    return true;
+  };
+
+  // 1. Local imported presets. filament_id lives in the preset's setting JSON,
+  // which the list endpoint doesn't return, so the generic material id is what
+  // we can offer without a per-preset detail fetch.
+  for (const lp of localPresets ?? []) {
+    const name = presetDisplayName(lp.name);
+    const material = lp.filament_type || parsePresetName(name).material;
+    // No id is claimed here: the generic id an import maps to is shared by
+    // every filament of that material, so claiming it would let the first
+    // imported PLA swallow every other PLA in the list.
+    if (!take('local', name)) continue;
+    options.push({
+      id: `local_${lp.id}`,
+      name,
+      source: 'local',
+      filamentId: genericFilamentIdForMaterial(material),
+      filamentType: material || '',
+    });
+  }
+
+  // 2. Orca Cloud. setting_ids are UUIDs a Bambu printer can't resolve, so
+  // these also fall back to the generic id for their material.
+  for (const op of orcaProfiles ?? []) {
+    const name = presetDisplayName(op.name);
+    const material = parsePresetName(name).material;
+    // Same reasoning as the local tier for the generic id. The Orca profile id
+    // is claimed, so a Bambu Cloud row carrying that same id is recognised as
+    // the same record — a shared *name* is not, since an Orca library and an
+    // imported bundle are usually the same profiles reached two ways and both
+    // are worth showing under their own heading.
+    if (!take('orca_cloud', name, op.setting_id)) continue;
+    options.push({
+      id: `orca_${op.setting_id}`,
+      name,
+      source: 'orca_cloud',
+      filamentId: genericFilamentIdForMaterial(material),
+      filamentType: material,
+    });
+  }
+
+  // 3. Bambu Cloud. Official presets (GFS…) carry their filament id in the
+  // setting_id itself; user presets (PFUS… / PFCN…) do not, and toFilamentId
+  // would hand back the raw cloud id, which the printer rejects. Leave those
+  // empty here and let resolveFilamentId fetch the detail on selection.
+  for (const cp of cloudSettings ?? []) {
+    const name = presetDisplayName(cp.name);
+    // Cloud setting_ids carry a variant suffix ("GFSA00_01"); claim the bare
+    // filament id as well, or the built-in tier won't recognise the filament
+    // as covered and will list it again under its own heading.
+    const filamentId = cp.setting_id.startsWith('GFS') ? toFilamentId(cp.setting_id) : '';
+    if (!take('cloud', name, cp.setting_id, filamentId || undefined)) continue;
+    options.push({
+      id: cp.setting_id,
+      name,
+      source: 'cloud',
+      filamentId,
+      filamentType: parsePresetName(name).material,
+    });
+  }
+
+  // 4. Hardcoded fallback. Always present, so the picker is never empty even
+  // with no cloud account and nothing imported — but only for filaments none
+  // of the tiers above already offered.
+  for (const bf of builtinFilaments ?? []) {
+    // Cloud setting_ids insert an "S" after "GF" ("GFA00" → "GFSA00"); check
+    // both spellings so a filament a cloud tier already offered isn't listed
+    // a second time under a slightly different id.
+    const asSettingId = bf.filament_id.startsWith('GF') ? `GFS${bf.filament_id.slice(2)}` : bf.filament_id;
+    // Unlike the tiers above, a name match is enough to skip: this table is a
+    // static copy of the same catalogue, not a library of its own.
+    if (namesOffered.has(nameKey(bf.name))) continue;
+    if (!take('builtin', bf.name, bf.filament_id, asSettingId)) continue;
+    options.push({
+      id: `builtin_${bf.filament_id}`,
+      name: bf.name,
+      source: 'builtin',
+      filamentId: bf.filament_id,
+      filamentType: parsePresetName(bf.name).material,
+    });
+  }
+
+  return options.sort((a, b) => {
+    if (a.source !== b.source) return SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source];
+    return a.name.localeCompare(b.name);
+  });
+}
+
+/**
+ * The Bambu filament id to file a calibration under for a chosen preset.
+ *
+ * Everything except a Bambu Cloud *user* preset is already resolved by
+ * buildFilamentPresetOptions; those need the cloud detail, because the
+ * PFUS/PFCN setting_id is not a filament id and the printer's calibration
+ * table is indexed by filament id. ``fetchDetail`` is injected so the pure
+ * cases stay testable without a network stub.
+ */
+export async function resolveFilamentId(
+  option: FilamentPresetOption,
+  fetchDetail?: (settingId: string) => Promise<{ filament_id?: string | null }>,
+): Promise<string> {
+  if (option.filamentId) return option.filamentId;
+  if (option.source !== 'cloud' || !fetchDetail) return '';
+  try {
+    const detail = await fetchDetail(option.id);
+    // Never fall back to the preset's base_id: that collapses a custom preset
+    // onto the generic it inherits from, and the printer then resolves the
+    // calibration to "Generic …" instead of the user's filament (#1053).
+    return detail.filament_id || '';
+  } catch {
+    return '';
+  }
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 0
static/assets/index-C_6BSgrK.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DPZgvI9N.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
static/assets/index-oReXTzKG.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
+    <script type="module" crossorigin src="/assets/index-DPZgvI9N.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
   </head>
   <body>
     <div id="root"></div>

+ 0 - 0
test_pipeline_archive_source.3mf


+ 0 - 0
test_pipeline_run_1.3mf


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů