Pārlūkot izejas kodu

fix: firmware upload only for idle printers (dialog, server, re-check after download, queue waits during upload); AMS Reset Slot asks first and is refused while printing; confirms for shopping list Clear all, Git history Clear and SSO unlink (last SSO link of an account without password stays); LDAP/SMTP test use the form's values; emptying a user's email removes it; local backup delete errors are shown; removed the unreachable printer-card Spoolman link/unlink

maziggy 2 dienas atpakaļ
vecāks
revīzija
770c6bfdf9
49 mainītis faili ar 1216 papildinājumiem un 266 dzēšanām
  1. 6 0
      CHANGELOG.md
  2. 52 36
      backend/app/api/routes/auth.py
  3. 10 1
      backend/app/api/routes/mfa.py
  4. 3 0
      backend/app/api/routes/printers.py
  5. 22 1
      backend/app/api/routes/users.py
  6. 18 0
      backend/app/schemas/auth.py
  7. 27 0
      backend/app/services/firmware_update.py
  8. 9 1
      backend/app/services/print_scheduler.py
  9. 188 0
      backend/tests/integration/test_connection_tests_use_the_form.py
  10. 177 0
      backend/tests/integration/test_idle_only_printer_actions.py
  11. 79 0
      backend/tests/integration/test_oidc_unlink_last_link.py
  12. 102 0
      backend/tests/integration/test_user_email_clear.py
  13. 56 9
      frontend/src/__tests__/components/ConfigureAmsSlotModalWiring.test.tsx
  14. 10 4
      frontend/src/__tests__/components/EmailSettingsWiring.test.tsx
  15. 11 9
      frontend/src/__tests__/components/ForecastPanelWiring.test.tsx
  16. 26 2
      frontend/src/__tests__/components/GitHubBackupSettingsWiring.test.tsx
  17. 16 3
      frontend/src/__tests__/components/LDAPSettingsWiring.test.tsx
  18. 9 1
      frontend/src/__tests__/components/TwoFactorSettingsWiring.test.tsx
  19. 21 0
      frontend/src/__tests__/pages/PrintersPageAmsSlotActionsWiring.test.tsx
  20. 37 8
      frontend/src/__tests__/pages/PrintersPageFirmwareModalWiring.test.tsx
  21. 14 0
      frontend/src/__tests__/pages/SettingsPageUsersWiring.test.tsx
  22. 20 1
      frontend/src/__tests__/pages/SpoolBuddyAmsPageWiring.test.tsx
  23. 16 1
      frontend/src/api/client.ts
  24. 29 4
      frontend/src/components/ConfigureAmsSlotModal.tsx
  25. 2 0
      frontend/src/components/EmailSettings.tsx
  26. 0 43
      frontend/src/components/FilamentHoverCard.tsx
  27. 14 1
      frontend/src/components/ForecastPanel.tsx
  28. 23 2
      frontend/src/components/GitHubBackupSettings.tsx
  29. 10 1
      frontend/src/components/LDAPSettings.tsx
  30. 17 1
      frontend/src/components/TwoFactorSettings.tsx
  31. 11 2
      frontend/src/i18n/locales/de.ts
  32. 11 2
      frontend/src/i18n/locales/en.ts
  33. 11 2
      frontend/src/i18n/locales/es.ts
  34. 11 2
      frontend/src/i18n/locales/fr.ts
  35. 11 2
      frontend/src/i18n/locales/it.ts
  36. 11 2
      frontend/src/i18n/locales/ja.ts
  37. 12 3
      frontend/src/i18n/locales/ko.ts
  38. 11 2
      frontend/src/i18n/locales/nl.ts
  39. 11 2
      frontend/src/i18n/locales/pt-BR.ts
  40. 11 2
      frontend/src/i18n/locales/ru.ts
  41. 11 2
      frontend/src/i18n/locales/sv.ts
  42. 11 2
      frontend/src/i18n/locales/tr.ts
  43. 11 2
      frontend/src/i18n/locales/uk.ts
  44. 11 2
      frontend/src/i18n/locales/zh-CN.ts
  45. 11 2
      frontend/src/i18n/locales/zh-TW.ts
  46. 16 104
      frontend/src/pages/PrintersPage.tsx
  47. 2 1
      frontend/src/pages/SettingsPage.tsx
  48. 1 0
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  49. 7 1
      frontend/src/utils/printer.ts

+ 6 - 0
CHANGELOG.md

@@ -164,6 +164,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Firmware could be uploaded to a printer in the middle of a print** — The firmware update dialog offered **Upload Firmware** whatever the printer was doing, and the upload wrote to the SD card the print runs from. It is now only offered while the printer is idle (a finished or failed print counts), and the server refuses it otherwise. If a print starts while the firmware is still downloading, nothing is written to the card, and the queue holds its next print for a printer until its firmware upload is over.
+- **Reset Slot cleared an AMS slot on a single click, even mid-print** — It now asks first, and is unavailable while a print is running, paused or starting, since the print may be feeding from that slot.
+- **Some destructive buttons acted without asking** — **Clear all** on the shopping list, **Clear** in the Git backup history and removing a linked SSO account now ask for confirmation first. An account created through SSO, which has no password, can no longer remove its last SSO link, which was its only way to sign in.
+- **LDAP and SMTP "Test" checked the saved settings, not the form** — Changing a field and clicking **Test Connection** or **Test Email** tested the old values, so a test could pass on settings that weren't the ones about to be saved. Both now test what the form holds, before saving. LDAP's test also works while LDAP is still disabled, and in that case now uses a saved CA certificate path too. An empty password keeps the saved one, as when saving; with a changed server it has to be entered again.
+- **A user's email couldn't be removed** — Emptying the email field when editing a user saved without error but kept the old address. It is now removed. The edit is refused while advanced authentication is on, or while the user signs in with email codes (email two-factor authentication), since both need the address.
+- **Deleting a local backup failed silently** — When the server refused or failed the delete, no message was shown and the file stayed in the list. The error is now shown.
 - **Purging archives could delete by an age that wasn't previewed** — The "Purge old archives" dialog previews what it will delete a moment after you stop typing, but the delete button took the age currently in the field. Changing the age and clicking straight away deleted archives for the new age while the button still showed the old preview's count. The button now waits until the preview matches the field, and the purge uses the previewed age.
 - **Editing a transaction to "No cost center" kept the old cost center** — Finance showed "Transaction updated", but the entry stayed booked to its previous cost center.
 - **After deleting a printer, spools still showed as loaded in it** — The spool assignment list wasn't refreshed, so the inventory kept showing spools in the deleted printer's slots for up to 30 seconds.

+ 52 - 36
backend/app/api/routes/auth.py

@@ -57,6 +57,7 @@ from backend.app.schemas.auth import (
     GroupBrief,
     LDAPProvisionRequest,
     LDAPSearchResultResponse,
+    LDAPTestRequest,
     LoginRequest,
     LoginResponse,
     ResetPasswordRequest,
@@ -853,15 +854,30 @@ async def test_smtp_connection(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Test SMTP connection using saved settings (admin only when auth enabled)."""
+    """Test SMTP connection using the form's settings, else the saved ones (admin only when auth enabled).
+
+    An empty password keeps the saved one, which only goes to the server it
+    was entered for.
+    """
     import logging
 
+    from backend.app.services.secret_fields import SMTP_DESTINATIONS, same_value
+
     logger = logging.getLogger(__name__)
 
     try:
-        smtp_settings = await get_smtp_settings(db)
+        stored = await get_smtp_settings(db)
+        smtp_settings = test_request.smtp or stored
         if not smtp_settings:
             return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
+        if test_request.smtp and smtp_settings.smtp_auth_enabled and not smtp_settings.smtp_password:
+            if stored and stored.smtp_password:
+                moved = [f for f in SMTP_DESTINATIONS if not same_value(getattr(smtp_settings, f), getattr(stored, f))]
+                if moved:
+                    return TestSMTPResponse(
+                        success=False, message=f"Enter the SMTP password again: {', '.join(moved)} changed"
+                    )
+                smtp_settings = smtp_settings.model_copy(update={"smtp_password": stored.smtp_password})
 
         # Send test email
         send_email(
@@ -1414,22 +1430,24 @@ async def reset_user_password(
 # LDAP Authentication Helpers
 
 
+LDAP_SETTING_KEYS = (
+    "ldap_enabled",
+    "ldap_server_url",
+    "ldap_bind_dn",
+    "ldap_bind_password",
+    "ldap_search_base",
+    "ldap_user_filter",
+    "ldap_security",
+    "ldap_group_mapping",
+    "ldap_auto_provision",
+    "ldap_ca_cert_path",
+    "ldap_default_group",
+)
+
+
 async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
     """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
-    ldap_keys = [
-        "ldap_enabled",
-        "ldap_server_url",
-        "ldap_bind_dn",
-        "ldap_bind_password",
-        "ldap_search_base",
-        "ldap_user_filter",
-        "ldap_security",
-        "ldap_group_mapping",
-        "ldap_auto_provision",
-        "ldap_ca_cert_path",
-        "ldap_default_group",
-    ]
-    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    result = await db.execute(select(Settings).where(Settings.key.in_(LDAP_SETTING_KEYS)))
     settings = {s.key: s.value for s in result.scalars().all()}
     if settings.get("ldap_enabled", "false").lower() != "true":
         return None
@@ -1545,37 +1563,35 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
 
 @router.post("/ldap/test")
 async def test_ldap(
+    body: LDAPTestRequest | None = None,
     _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Test LDAP connection using saved settings (admin only when auth enabled)."""
+    """Test the LDAP connection with the form's settings over the saved ones (admin only when auth enabled).
+
+    Works while LDAP is still off. An empty bind password keeps the saved one,
+    which only goes to the server it was entered for.
+    """
     import logging
 
     from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
+    from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, merge_partial
 
     logger = logging.getLogger(__name__)
 
-    ldap_settings = await _get_ldap_settings(db)
-    if not ldap_settings:
-        # LDAP might not be enabled yet but settings might still exist — read all keys
-        ldap_keys = [
-            "ldap_enabled",
-            "ldap_server_url",
-            "ldap_bind_dn",
-            "ldap_bind_password",
-            "ldap_search_base",
-            "ldap_user_filter",
-            "ldap_security",
-            "ldap_group_mapping",
-            "ldap_auto_provision",
-        ]
-        result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
-        ldap_settings = {s.key: s.value for s in result.scalars().all()}
-        # Force enabled for test
-        ldap_settings["ldap_enabled"] = "true"
+    result = await db.execute(select(Settings).where(Settings.key.in_(LDAP_SETTING_KEYS)))
+    # Unset security is StartTLS, as when saving.
+    stored = {"ldap_security": "starttls", **{s.key: s.value for s in result.scalars().all()}}
+    form = body.model_dump(exclude_none=True) if body else {}
+    if not form.get("ldap_bind_password"):
+        form.pop("ldap_bind_password", None)
+    try:
+        form = merge_partial(stored, form, {"ldap_bind_password": SETTINGS_SECRETS["ldap_bind_password"]})
+    except SecretConfigError as e:
+        return {"success": False, "message": str(e)}
 
-    config = parse_ldap_config(ldap_settings)
+    config = parse_ldap_config({**stored, **form, "ldap_enabled": "true"})
     if not config:
         return {"success": False, "message": "LDAP server URL is not configured"}
 

+ 10 - 1
backend/app/api/routes/mfa.py

@@ -34,7 +34,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from passlib.context import CryptContext
-from sqlalchemy import delete, select, update
+from sqlalchemy import delete, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 
@@ -2326,6 +2326,15 @@ async def remove_oidc_link(
     link = result.scalar_one_or_none()
     if not link:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC link not found")
+    # A user created from an SSO login has no password: their last link is
+    # their only way to sign in.
+    if current_user.auth_source == "oidc" and not current_user.password_hash:
+        links = await db.execute(select(func.count()).where(UserOIDCLink.user_id == current_user.id))
+        if links.scalar_one() <= 1:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="This is the only way this account can sign in; it can't be unlinked",
+            )
 
     await db.delete(link)
     await db.commit()

+ 3 - 0
backend/app/api/routes/printers.py

@@ -3405,6 +3405,9 @@ async def reset_ams_slot(
     client = printer_manager.get_client(printer_id)
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
+    # A running print may be feeding from this tray.
+    if client.state and client.state.state in printer_manager.ACTIVE_PRINT_STATES:
+        raise HTTPException(status_code=409, detail="The printer is printing; reset the slot once it is idle")
 
     # Reset the slot
     success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)

+ 22 - 1
backend/app/api/routes/users.py

@@ -339,7 +339,28 @@ async def update_user(
             close_sockets = True
         user.username = user_data.username
 
-    if user_data.email is not None:
+    if user_data.email is not None and not user_data.email.strip():
+        # Empty removes the email; null leaves it as it is.
+        if user.email:
+            rows = await db.execute(
+                select(Settings.key, Settings.value).where(
+                    Settings.key.in_(["advanced_auth_enabled", f"user_{user.id}_email_2fa_enabled"])
+                )
+            )
+            flags = {key: (value or "").lower() == "true" for key, value in rows.all()}
+            if flags.get("advanced_auth_enabled"):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail="Email is required when advanced authentication is enabled",
+                )
+            # Their sign-in codes go to this address.
+            if flags.get(f"user_{user.id}_email_2fa_enabled"):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail="Email is required while the user has email two-factor authentication on",
+                )
+            user.email = None
+    elif user_data.email is not None:
         # Check if new email already exists (case-insensitive)
         existing_email = await db.execute(
             select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)

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

@@ -122,6 +122,21 @@ class LDAPSearchResultResponse(BaseModel):
     already_provisioned: bool = False  # True if this username already exists as a BamBuddy user
 
 
+class LDAPTestRequest(BaseModel):
+    """The LDAP settings in the form, tested before they are saved.
+
+    A field left out uses the saved value; an empty bind password keeps the
+    saved one.
+    """
+
+    ldap_server_url: str | None = None
+    ldap_bind_dn: str | None = None
+    ldap_bind_password: str | None = None
+    ldap_search_base: str | None = None
+    ldap_user_filter: str | None = None
+    ldap_security: str | None = None
+
+
 class LDAPProvisionRequest(BaseModel):
     """Body for POST /auth/ldap/provision. Username is re-resolved via the
     service-account bind, so the request only carries the directory username
@@ -198,6 +213,9 @@ class SMTPSettings(BaseModel):
 
 class TestSMTPRequest(BaseModel):
     test_recipient: str
+    # The settings in the form, tested before they are saved; left out, the
+    # saved ones. An empty password keeps the saved one.
+    smtp: SMTPSettings | None = None
 
 
 class TestSMTPResponse(BaseModel):

+ 27 - 0
backend/app/services/firmware_update.py

@@ -28,6 +28,17 @@ from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
 
+# States with no print loaded. Firmware goes onto the SD card the printer
+# prints from, so only then.
+IDLE_STATES = frozenset({"IDLE", "FINISH", "FAILED"})
+PRINTER_BUSY_ERROR = "The printer is busy; firmware can only be uploaded while it is idle"
+
+
+def _printer_idle(printer_id: int) -> bool:
+    client = printer_manager.get_client(printer_id)
+    state = client.state if client else None
+    return bool(state) and state.state in IDLE_STATES
+
 
 class FirmwareUploadStatus(StrEnum):
     """Status of a firmware upload operation."""
@@ -56,6 +67,16 @@ class FirmwareUploadState:
 _upload_states: dict[int, FirmwareUploadState] = {}
 
 
+def upload_in_progress(printer_id: int) -> bool:
+    """Firmware is being fetched for, or written to, this printer's SD card."""
+    state = _upload_states.get(printer_id)
+    return state is not None and state.status in (
+        FirmwareUploadStatus.PREPARING,
+        FirmwareUploadStatus.DOWNLOADING,
+        FirmwareUploadStatus.UPLOADING,
+    )
+
+
 def get_upload_state(printer_id: int) -> FirmwareUploadState:
     """Get the current upload state for a printer."""
     if printer_id not in _upload_states:
@@ -126,6 +147,8 @@ class FirmwareUpdateService:
             return result
 
         state = mqtt_client.state
+        if state.state not in IDLE_STATES:
+            result["errors"].append(PRINTER_BUSY_ERROR)
 
         # Get current firmware version
         result["current_version"] = state.firmware_version
@@ -302,6 +325,10 @@ class FirmwareUpdateService:
                 if latest:
                     state.firmware_version = latest.version
 
+            # The download can take minutes; a print may have started since.
+            if not _printer_idle(printer_id):
+                raise Exception(PRINTER_BUSY_ERROR)
+
             # Upload to printer (0-100% progress shown here)
             state.status = FirmwareUploadStatus.UPLOADING
             state.progress = 0

+ 9 - 1
backend/app/services/print_scheduler.py

@@ -5025,7 +5025,15 @@ class PrintScheduler:
         idle = state.state in ("IDLE", "FINISH", "FAILED")
         if not idle:
             logger.debug("Printer %d: not idle — state=%s", printer_id, state.state)
-        return idle
+            return False
+
+        # A print would run from the SD card the firmware is being written to.
+        from backend.app.services.firmware_update import upload_in_progress
+
+        if upload_in_progress(printer_id):
+            logger.debug("Printer %d: not idle — firmware upload in progress", printer_id)
+            return False
+        return True
 
     @staticmethod
     def _pinned_hold_reason(printer_id: int, printer_name: str, require_plate_clear: bool) -> str:

+ 188 - 0
backend/tests/integration/test_connection_tests_use_the_form.py

@@ -0,0 +1,188 @@
+"""The LDAP and SMTP "Test" buttons test what the form holds, not only what was saved.
+
+A password left empty in the form keeps the saved one, which still only goes
+to the server it was entered for.
+"""
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+LDAP_SAVED = {
+    "ldap_server_url": "ldaps://ldap.example.com",
+    "ldap_bind_dn": "cn=svc,dc=example,dc=com",
+    "ldap_bind_password": "saved-bind-secret",
+    "ldap_search_base": "dc=example,dc=com",
+    "ldap_security": "ldaps",
+}
+
+SMTP_SAVED = {
+    "smtp_host": "smtp.example.com",
+    "smtp_port": 587,
+    "smtp_username": "mailer@example.com",
+    "smtp_password": "saved-smtp-secret",
+    "smtp_security": "starttls",
+    "smtp_auth_enabled": True,
+    "smtp_from_email": "noreply@example.com",
+}
+
+
+async def _ldap_test(async_client: AsyncClient, body: dict | None):
+    tested = []
+
+    def fake_test(config):
+        tested.append(config)
+        return True, "ok"
+
+    with patch("backend.app.services.ldap_service.test_ldap_connection", side_effect=fake_test):
+        if body is None:
+            response = await async_client.post("/api/v1/auth/ldap/test")
+        else:
+            response = await async_client.post("/api/v1/auth/ldap/test", json=body)
+    assert response.status_code == 200, response.text
+    return response.json(), tested
+
+
+@pytest.fixture
+async def ldap_saved(async_client: AsyncClient):
+    response = await async_client.put("/api/v1/settings/", json=LDAP_SAVED)
+    assert response.status_code == 200, response.text
+
+
+class TestLdap:
+    @pytest.mark.asyncio
+    async def test_tests_the_form_values(self, async_client, ldap_saved):
+        result, tested = await _ldap_test(
+            async_client,
+            {
+                **LDAP_SAVED,
+                "ldap_bind_dn": "cn=other,dc=example,dc=com",
+                "ldap_bind_password": "typed-secret",
+                "ldap_search_base": "ou=people,dc=example,dc=com",
+            },
+        )
+
+        assert result["success"] is True
+        assert tested[0].bind_dn == "cn=other,dc=example,dc=com"
+        assert tested[0].bind_password == "typed-secret"
+        assert tested[0].search_base == "ou=people,dc=example,dc=com"
+
+    @pytest.mark.asyncio
+    async def test_an_empty_password_keeps_the_saved_one_for_the_same_server(self, async_client, ldap_saved):
+        _, tested = await _ldap_test(async_client, {**LDAP_SAVED, "ldap_bind_password": ""})
+
+        assert tested[0].bind_password == "saved-bind-secret"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "changed",
+        [{"ldap_server_url": "ldaps://elsewhere.example.net"}, {"ldap_security": "starttls"}],
+    )
+    async def test_the_saved_password_never_goes_to_another_server(self, async_client, ldap_saved, changed):
+        result, tested = await _ldap_test(async_client, {**LDAP_SAVED, "ldap_bind_password": "", **changed})
+
+        assert result["success"] is False
+        assert "again" in result["message"]
+        assert tested == []
+
+    @pytest.mark.asyncio
+    async def test_without_a_body_it_tests_the_saved_settings(self, async_client, ldap_saved):
+        _, tested = await _ldap_test(async_client, None)
+
+        assert tested[0].server_url == "ldaps://ldap.example.com"
+        assert tested[0].bind_password == "saved-bind-secret"
+
+    @pytest.mark.asyncio
+    async def test_a_saved_ca_certificate_is_used_while_ldap_is_off(self, async_client, db_session, ldap_saved):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="ldap_ca_cert_path", value="/certs/ca.pem"))
+        await db_session.commit()
+
+        _, tested = await _ldap_test(async_client, {**LDAP_SAVED, "ldap_bind_password": ""})
+
+        assert tested[0].ca_cert_path == "/certs/ca.pem"
+
+
+@pytest.mark.asyncio
+async def test_ldap_unset_security_is_starttls_as_when_saving(async_client, db_session):
+    """Settings saved without a security row: the form's StartTLS is no change of destination."""
+    from backend.app.models.settings import Settings
+
+    for key, value in {**LDAP_SAVED, "ldap_server_url": "ldap://ldap.example.com"}.items():
+        if key != "ldap_security":
+            db_session.add(Settings(key=key, value=value))
+    await db_session.commit()
+
+    result, tested = await _ldap_test(
+        async_client,
+        {
+            **LDAP_SAVED,
+            "ldap_server_url": "ldap://ldap.example.com",
+            "ldap_security": "starttls",
+            "ldap_bind_password": "",
+        },
+    )
+
+    assert result["success"] is True, result
+    assert tested[0].bind_password == "saved-bind-secret"
+
+
+async def _smtp_test(async_client: AsyncClient, smtp: dict | None):
+    body: dict = {"test_recipient": "someone@example.com"}
+    if smtp is not None:
+        body["smtp"] = smtp
+    with patch("backend.app.api.routes.auth.send_email") as send:
+        response = await async_client.post("/api/v1/auth/smtp/test", json=body)
+    assert response.status_code == 200, response.text
+    return response.json(), send
+
+
+@pytest.fixture
+async def smtp_saved(async_client: AsyncClient):
+    response = await async_client.post("/api/v1/auth/smtp", json=SMTP_SAVED)
+    assert response.status_code == 200, response.text
+
+
+class TestSmtp:
+    @pytest.mark.asyncio
+    async def test_tests_the_form_values(self, async_client, smtp_saved):
+        result, send = await _smtp_test(
+            async_client, {**SMTP_SAVED, "smtp_from_email": "typed@example.com", "smtp_password": "typed-secret"}
+        )
+
+        assert result["success"] is True
+        used = send.call_args.kwargs["smtp_settings"]
+        assert used.smtp_from_email == "typed@example.com"
+        assert used.smtp_password == "typed-secret"
+
+    @pytest.mark.asyncio
+    async def test_an_empty_password_keeps_the_saved_one_for_the_same_server(self, async_client, smtp_saved):
+        _, send = await _smtp_test(async_client, {**SMTP_SAVED, "smtp_password": ""})
+
+        assert send.call_args.kwargs["smtp_settings"].smtp_password == "saved-smtp-secret"
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "changed", [{"smtp_host": "mail.elsewhere.example.net"}, {"smtp_port": 2525}, {"smtp_security": "ssl"}]
+    )
+    async def test_the_saved_password_never_goes_to_another_server(self, async_client, smtp_saved, changed):
+        result, send = await _smtp_test(async_client, {**SMTP_SAVED, "smtp_password": "", **changed})
+
+        assert result["success"] is False
+        assert "again" in result["message"]
+        send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_without_the_form_it_tests_the_saved_settings(self, async_client, smtp_saved):
+        _, send = await _smtp_test(async_client, None)
+
+        assert send.call_args.kwargs["smtp_settings"].smtp_host == "smtp.example.com"
+
+    @pytest.mark.asyncio
+    async def test_the_form_works_before_anything_was_saved(self, async_client):
+        result, send = await _smtp_test(async_client, SMTP_SAVED)
+
+        assert result["success"] is True
+        assert send.call_args.kwargs["smtp_settings"].smtp_password == "saved-smtp-secret"

+ 177 - 0
backend/tests/integration/test_idle_only_printer_actions.py

@@ -0,0 +1,177 @@
+"""Actions that only an idle printer takes: firmware upload and AMS slot reset.
+
+Firmware goes onto the SD card the printer prints from, and a slot reset
+clears the filament setting of a tray a running print may be feeding from.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.firmware_update import FirmwareUpdateService, get_upload_state, reset_upload_state
+
+
+def _client(state: str) -> MagicMock:
+    client = MagicMock()
+    client.state = SimpleNamespace(state=state, firmware_version="01.06.00.00", sdcard=True, connected=True)
+    client.reset_ams_slot.return_value = True
+    return client
+
+
+def _firmware_service() -> MagicMock:
+    service = MagicMock()
+    service.check_for_update = AsyncMock(return_value={"update_available": True, "latest_version": "01.07.00.00"})
+    service.get_firmware_file_info = AsyncMock(return_value={"filename": "fw.bin"})
+    service.download_firmware = AsyncMock()
+    service.get_latest_version = AsyncMock(return_value=None)
+    return service
+
+
+def _patched(client: MagicMock, service: MagicMock):
+    return (
+        patch("backend.app.services.firmware_update.printer_manager.get_client", return_value=client),
+        patch("backend.app.services.firmware_update.get_firmware_service", return_value=service),
+        patch(
+            "backend.app.services.firmware_update.get_storage_info_async",
+            AsyncMock(return_value={"free_bytes": 10 * 1024**3}),
+        ),
+    )
+
+
+class TestFirmwareUpload:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING", "unknown"])
+    async def test_a_busy_printer_is_refused_in_the_prepare_check(self, async_client, printer_factory, state):
+        printer = await printer_factory()
+        a, b, c = _patched(_client(state), _firmware_service())
+        with a, b, c:
+            response = await async_client.get(f"/api/v1/firmware/updates/{printer.id}/prepare")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["can_proceed"] is False
+        assert any("idle" in error for error in body["errors"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("state", ["IDLE", "FINISH", "FAILED"])
+    async def test_an_idle_printer_passes_the_prepare_check(self, async_client, printer_factory, state):
+        printer = await printer_factory()
+        a, b, c = _patched(_client(state), _firmware_service())
+        with a, b, c:
+            response = await async_client.get(f"/api/v1/firmware/updates/{printer.id}/prepare")
+
+        assert response.json()["can_proceed"] is True, response.json()["errors"]
+
+    @pytest.mark.asyncio
+    async def test_the_upload_route_refuses_a_printing_printer(self, async_client, printer_factory):
+        printer = await printer_factory()
+        a, b, c = _patched(_client("RUNNING"), _firmware_service())
+        with (
+            a,
+            b,
+            c,
+            patch.object(FirmwareUpdateService, "start_upload", AsyncMock(return_value=True)) as start,
+        ):
+            response = await async_client.post(f"/api/v1/firmware/updates/{printer.id}/upload")
+
+        assert response.status_code == 400
+        assert "idle" in response.json()["detail"]
+        start.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_print_started_during_the_download_gets_no_firmware(self, tmp_path):
+        """The download can take minutes; the card is checked again before writing to it."""
+        client = _client("IDLE")
+        service = _firmware_service()
+        firmware = tmp_path / "fw.bin"
+        firmware.write_bytes(b"x")
+
+        async def download(*_args, **_kwargs):
+            client.state.state = "RUNNING"
+            return firmware
+
+        service.download_firmware = AsyncMock(side_effect=download)
+        upload = AsyncMock(return_value=True)
+        reset_upload_state(9101)
+        with (
+            patch("backend.app.services.firmware_update.printer_manager.get_client", return_value=client),
+            patch("backend.app.services.firmware_update.get_firmware_service", return_value=service),
+            patch("backend.app.services.firmware_update.upload_file_async", upload),
+            patch(
+                "backend.app.services.firmware_update.get_ftp_retry_settings",
+                AsyncMock(return_value=(False, 0, 0, 30)),
+            ),
+        ):
+            await FirmwareUpdateService()._do_upload(9101, "192.0.2.10", "12345678", "X1C", "01.07.00.00")
+
+        upload.assert_not_called()
+        state = get_upload_state(9101)
+        assert state.status.value == "error"
+        assert "idle" in (state.error or "")
+        reset_upload_state(9101)
+
+
+class TestAmsSlotReset:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("state", ["RUNNING", "PAUSE", "PREPARE", "SLICING"])
+    async def test_refused_while_a_print_is_loaded(self, async_client, printer_factory, state):
+        printer = await printer_factory()
+        client = _client(state)
+        with patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/tray/1/reset")
+
+        assert response.status_code == 409
+        client.reset_ams_slot.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("state", ["IDLE", "FINISH", "FAILED"])
+    async def test_sent_when_no_print_is_loaded(self, async_client, printer_factory, state):
+        printer = await printer_factory()
+        client = _client(state)
+        with patch("backend.app.api.routes.printers.printer_manager.get_client", return_value=client):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/0/tray/1/reset")
+
+        assert response.status_code == 200
+        client.reset_ams_slot.assert_called_once_with(ams_id=0, tray_id=1)
+
+
+class TestQueueDuringFirmwareUpload:
+    """A queued print does not start while firmware is being put on the printer's card."""
+
+    @pytest.fixture
+    def idle_printer(self):
+        from backend.app.services.printer_manager import printer_manager
+
+        printer_id = 9102
+        client = MagicMock()
+        client.state = SimpleNamespace(state="IDLE", connected=True)
+        printer_manager._clients[printer_id] = client
+        reset_upload_state(printer_id)
+        try:
+            with (
+                patch.object(printer_manager, "is_connected", return_value=True),
+                patch.object(printer_manager, "get_status", return_value=client.state),
+            ):
+                yield printer_id
+        finally:
+            printer_manager._clients.pop(printer_id, None)
+            reset_upload_state(printer_id)
+
+    @pytest.mark.parametrize("status", ["preparing", "downloading", "uploading"])
+    def test_not_idle_while_an_upload_runs(self, idle_printer, status):
+        from backend.app.services.firmware_update import FirmwareUploadStatus
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        get_upload_state(idle_printer).status = FirmwareUploadStatus(status)
+
+        assert PrintScheduler()._is_printer_idle(idle_printer, require_plate_clear=False) is False
+
+    @pytest.mark.parametrize("status", ["idle", "complete", "error"])
+    def test_idle_again_once_it_is_over(self, idle_printer, status):
+        from backend.app.services.firmware_update import FirmwareUploadStatus
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        get_upload_state(idle_printer).status = FirmwareUploadStatus(status)
+
+        assert PrintScheduler()._is_printer_idle(idle_printer, require_plate_clear=False) is True

+ 79 - 0
backend/tests/integration/test_oidc_unlink_last_link.py

@@ -0,0 +1,79 @@
+"""Removing a linked SSO account never removes a user's last way to sign in.
+
+A user Bambuddy created from an SSO login has no password, so their last
+link stays; a user with a password, or with another link, can remove it.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core.auth import create_access_token, get_password_hash
+from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+from backend.app.models.user import User
+from backend.tests.integration.test_mfa_api import _setup_and_login
+
+
+async def _provider(db_session, name: str) -> int:
+    provider = OIDCProvider(name=name, issuer_url=f"https://{name}.example.com", client_id="bambuddy")
+    provider.client_secret = "provider-secret"
+    db_session.add(provider)
+    await db_session.commit()
+    return provider.id
+
+
+async def _user(db_session, *, oidc_only: bool, providers: list[int]) -> tuple[User, dict]:
+    user = User(
+        username="sso-user",
+        password_hash=None if oidc_only else get_password_hash("Whatever1!"),
+        auth_source="oidc" if oidc_only else "local",
+        role="user",
+        is_active=True,
+    )
+    db_session.add(user)
+    await db_session.commit()
+    for i, provider_id in enumerate(providers):
+        db_session.add(UserOIDCLink(user_id=user.id, provider_id=provider_id, provider_user_id=f"sub-{i}"))
+    await db_session.commit()
+    return user, {"Authorization": f"Bearer {create_access_token(data={'sub': user.username})}"}
+
+
+async def _links(db_session, user_id: int) -> int:
+    db_session.expire_all()
+    return len((await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.user_id == user_id))).all())
+
+
+@pytest.mark.asyncio
+async def test_the_last_link_of_a_user_without_password_stays(async_client: AsyncClient, db_session):
+    await _setup_and_login(async_client, "ssoadmin", "SsoAdmin12!")
+    provider = await _provider(db_session, "alpha")
+    user, headers = await _user(db_session, oidc_only=True, providers=[provider])
+
+    response = await async_client.delete(f"/api/v1/auth/oidc/links/{provider}", headers=headers)
+
+    assert response.status_code == 400
+    assert await _links(db_session, user.id) == 1
+
+
+@pytest.mark.asyncio
+async def test_a_user_without_password_can_remove_one_of_two_links(async_client: AsyncClient, db_session):
+    await _setup_and_login(async_client, "ssoadmin", "SsoAdmin12!")
+    first, second = await _provider(db_session, "alpha"), await _provider(db_session, "beta")
+    user, headers = await _user(db_session, oidc_only=True, providers=[first, second])
+
+    response = await async_client.delete(f"/api/v1/auth/oidc/links/{first}", headers=headers)
+
+    assert response.status_code == 200, response.text
+    assert await _links(db_session, user.id) == 1
+
+
+@pytest.mark.asyncio
+async def test_a_user_with_a_password_can_remove_the_last_link(async_client: AsyncClient, db_session):
+    await _setup_and_login(async_client, "ssoadmin", "SsoAdmin12!")
+    provider = await _provider(db_session, "alpha")
+    user, headers = await _user(db_session, oidc_only=False, providers=[provider])
+
+    response = await async_client.delete(f"/api/v1/auth/oidc/links/{provider}", headers=headers)
+
+    assert response.status_code == 200, response.text
+    assert await _links(db_session, user.id) == 0

+ 102 - 0
backend/tests/integration/test_user_email_clear.py

@@ -0,0 +1,102 @@
+"""Editing a user can remove their email: an empty email clears it.
+
+Left out or null, the email stays as it is. With advanced authentication on,
+a user needs an email (password resets go there), so it can't be removed.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+async def _user(db_session, email: str | None = "mia@example.invalid") -> User:
+    from backend.app.core.auth import get_password_hash
+
+    user = User(username="mia", password_hash=get_password_hash("Whatever1!"), email=email, role="user", is_active=True)
+    db_session.add(user)
+    await db_session.commit()
+    return user
+
+
+async def _email(db_session, user_id: int) -> str | None:
+    db_session.expire_all()
+    return (await db_session.execute(select(User.email).where(User.id == user_id))).scalar_one()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("email", ["", "   "])
+async def test_an_empty_email_clears_it(async_client: AsyncClient, db_session, email):
+    user = await _user(db_session)
+
+    response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": email})
+
+    assert response.status_code == 200, response.text
+    assert response.json()["email"] is None
+    assert await _email(db_session, user.id) is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("body", [{}, {"email": None}, {"role": "user"}])
+async def test_left_out_or_null_keeps_it(async_client: AsyncClient, db_session, body):
+    user = await _user(db_session)
+
+    response = await async_client.patch(f"/api/v1/users/{user.id}", json=body)
+
+    assert response.status_code == 200, response.text
+    assert await _email(db_session, user.id) == "mia@example.invalid"
+
+
+@pytest.mark.asyncio
+async def test_two_users_without_email_are_no_duplicate(async_client: AsyncClient, db_session):
+    from backend.app.core.auth import get_password_hash
+
+    db_session.add(User(username="other", password_hash=get_password_hash("Whatever1!"), email=None, role="user"))
+    user = await _user(db_session)
+
+    response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": ""})
+
+    assert response.status_code == 200, response.text
+
+
+@pytest.mark.asyncio
+async def test_refused_while_advanced_authentication_is_on(async_client: AsyncClient, db_session):
+    user = await _user(db_session)
+    db_session.add(Settings(key="advanced_auth_enabled", value="true"))
+    await db_session.commit()
+
+    response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": ""})
+
+    assert response.status_code == 400
+    assert "advanced authentication" in response.json()["detail"]
+    assert await _email(db_session, user.id) == "mia@example.invalid"
+
+
+@pytest.mark.asyncio
+async def test_a_user_without_email_can_still_be_edited_with_advanced_authentication(
+    async_client: AsyncClient, db_session
+):
+    user = await _user(db_session, email=None)
+    db_session.add(Settings(key="advanced_auth_enabled", value="true"))
+    await db_session.commit()
+
+    response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": "", "role": "user"})
+
+    assert response.status_code == 200, response.text
+    assert await _email(db_session, user.id) is None
+
+
+@pytest.mark.asyncio
+async def test_refused_while_the_user_signs_in_with_an_email_code(async_client: AsyncClient, db_session):
+    """Email 2FA needs SMTP, not advanced authentication: the code goes to this address."""
+    user = await _user(db_session)
+    db_session.add(Settings(key=f"user_{user.id}_email_2fa_enabled", value="true"))
+    await db_session.commit()
+
+    response = await async_client.patch(f"/api/v1/users/{user.id}", json={"email": ""})
+
+    assert response.status_code == 400
+    assert "email" in response.json()["detail"].lower()
+    assert await _email(db_session, user.id) == "mia@example.invalid"

+ 56 - 9
frontend/src/__tests__/components/ConfigureAmsSlotModalWiring.test.tsx

@@ -1,10 +1,11 @@
 /**
  * Wiring tests for ConfigureAmsSlotModal's "Reset Slot" path: the request
  * must target exactly the opened slot (printer, AMS id, tray id), including
- * AMS-HT (128+) and external (255/254) ids.
+ * AMS-HT (128+) and external (255/254) ids, only after a confirm, and never
+ * while a print is loaded.
  */
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within, fireEvent } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
@@ -36,7 +37,7 @@ const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
 const onClose = vi.fn();
 const onSuccess = vi.fn();
 
-function props(amsId: number, trayId: number, printerId = 9) {
+function props(amsId: number, trayId: number, printerId = 9, printerState: string | null = 'IDLE') {
   return {
     isOpen: true,
     onClose,
@@ -44,9 +45,14 @@ function props(amsId: number, trayId: number, printerId = 9) {
     printerId,
     slotInfo: { amsId, trayId, trayType: 'PLA', trayColor: 'FFFFFF', traySubBrands: 'PLA Basic' },
     nozzleDiameter: '0.4',
+    printerState,
   };
 }
 
+function confirmDialog(): HTMLElement {
+  return screen.getByText('Reset slot?').closest('div.fixed') as HTMLElement;
+}
+
 describe('ConfigureAmsSlotModal reset wiring', () => {
   beforeEach(() => {
     vi.clearAllMocks();
@@ -73,6 +79,8 @@ describe('ConfigureAmsSlotModal reset wiring', () => {
     render(<ConfigureAmsSlotModal {...props(amsId, trayId)} />);
 
     await user.click(await screen.findByRole('button', { name: /Reset Slot/ }));
+    expect(mocked.resetAmsSlot).not.toHaveBeenCalled();
+    await user.click(within(confirmDialog()).getByRole('button', { name: 'Reset Slot' }));
 
     await waitFor(() => expect(mocked.resetAmsSlot).toHaveBeenCalledTimes(1));
     expect(mocked.resetAmsSlot).toHaveBeenCalledWith(9, amsId, trayId);
@@ -90,10 +98,49 @@ describe('ConfigureAmsSlotModal reset wiring', () => {
     expect(mocked.configureAmsSlot).not.toHaveBeenCalled();
   });
 
-  // NOTE: the audit item also asked to pin "reset requires confirm and is
-  // disabled while printing". Neither exists in the component today: the
-  // button calls resetAmsSlot on the first click and takes no printer-state
-  // prop. Left as todo for a product decision rather than asserted.
-  it.todo('Reset Slot asks for confirmation before sending (not implemented)');
-  it.todo('Reset Slot is disabled while the printer is printing (not implemented)');
+  it('cancelling the confirm sends nothing and keeps the slot dialog open', async () => {
+    const user = userEvent.setup();
+    render(<ConfigureAmsSlotModal {...props(2, 3)} />);
+
+    await user.click(await screen.findByRole('button', { name: /Reset Slot/ }));
+    await user.click(within(confirmDialog()).getByRole('button', { name: 'Cancel' }));
+
+    expect(screen.queryByText('Reset slot?')).toBeNull();
+    expect(mocked.resetAmsSlot).not.toHaveBeenCalled();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('Escape in the confirm closes only the confirm', async () => {
+    const user = userEvent.setup();
+    render(<ConfigureAmsSlotModal {...props(2, 3)} />);
+
+    await user.click(await screen.findByRole('button', { name: /Reset Slot/ }));
+    // One key press: it bubbles through document (the slot dialog) to window (the confirm).
+    fireEvent.keyDown(document.body, { key: 'Escape' });
+
+    expect(screen.queryByText('Reset slot?')).toBeNull();
+    expect(onClose).not.toHaveBeenCalled();
+    expect(mocked.resetAmsSlot).not.toHaveBeenCalled();
+  });
+
+  it.each(['RUNNING', 'PAUSE', 'PREPARE', 'SLICING'])(
+    'Reset Slot is disabled while the printer is %s',
+    async (state) => {
+      const user = userEvent.setup();
+      render(<ConfigureAmsSlotModal {...props(2, 3, 9, state)} />);
+
+      const reset = await screen.findByRole('button', { name: /Reset Slot/ });
+      expect(reset).toBeDisabled();
+      await user.click(reset);
+
+      expect(screen.queryByText('Reset slot?')).toBeNull();
+      expect(mocked.resetAmsSlot).not.toHaveBeenCalled();
+    },
+  );
+
+  it.each(['IDLE', 'FINISH', 'FAILED'])('Reset Slot is available while the printer is %s', async (state) => {
+    render(<ConfigureAmsSlotModal {...props(2, 3, 9, state)} />);
+
+    expect(await screen.findByRole('button', { name: /Reset Slot/ })).toBeEnabled();
+  });
 });

+ 10 - 4
frontend/src/__tests__/components/EmailSettingsWiring.test.tsx

@@ -1,8 +1,8 @@
 /**
  * Email settings: the SMTP save request carries the form values (and an
  * empty password when it was not retyped, so the server keeps the stored
- * one), and the advanced-auth toggle only calls enable/disable when its
- * preconditions hold.
+ * one), the test email goes out with the form's settings, and the
+ * advanced-auth toggle only calls enable/disable when its preconditions hold.
  */
 
 import { describe, it, expect, beforeEach } from 'vitest';
@@ -120,11 +120,14 @@ describe('EmailSettings wiring', () => {
     expect(captured).toHaveLength(0);
   });
 
-  it('send test email posts the recipient to /auth/smtp/test', async () => {
+  it('send test email posts the recipient and the form as it is, unsaved edits included', async () => {
     const user = userEvent.setup();
     render(<EmailSettings />);
 
     await screen.findByDisplayValue('smtp.example');
+    const from = screen.getByDisplayValue('from@example.com');
+    await user.clear(from);
+    await user.type(from, 'typed@example.com');
     await user.type(screen.getByPlaceholderText('test@example.com'), 'probe@example.com');
     await user.click(screen.getByRole('button', { name: /send test email/i }));
 
@@ -132,7 +135,10 @@ describe('EmailSettings wiring', () => {
     expect(captured[0]).toEqual({
       method: 'POST',
       path: '/api/v1/auth/smtp/test',
-      body: { test_recipient: 'probe@example.com' },
+      body: {
+        test_recipient: 'probe@example.com',
+        smtp: { ...STORED_SMTP, smtp_password: '', smtp_from_email: 'typed@example.com' },
+      },
     });
   });
 

+ 11 - 9
frontend/src/__tests__/components/ForecastPanelWiring.test.tsx

@@ -184,26 +184,28 @@ describe('ForecastPanel wiring', () => {
     expect(mocked.clearShoppingList).not.toHaveBeenCalled();
   });
 
-  // BUG: "Clear all" wipes the whole shopping list on a single click
-  // (ForecastPanel.tsx ShoppingListPanel onClear -> api.clearShoppingList with
-  // no ConfirmModal). Every other bulk-destructive inventory action confirms.
-  it.skip('clear all asks for confirmation before deleting the shopping list', async () => {
+  it('clear all asks first; cancel sends nothing', async () => {
     const user = userEvent.setup();
     render(<ForecastPanel spools={spools} />);
     await openShoppingList(user);
     await user.click(screen.getByText('Clear all'));
-    // Nothing may be sent before the user confirms.
-    expect(mocked.clearShoppingList).not.toHaveBeenCalled();
-    const cancels = screen.getAllByRole('button', { name: 'Cancel' });
-    await user.click(cancels[cancels.length - 1]);
+
+    const dialog = screen.getByText('Clear shopping list?').closest('div.fixed') as HTMLElement;
+    await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
+
+    expect(screen.queryByText('Clear shopping list?')).toBeNull();
     expect(mocked.clearShoppingList).not.toHaveBeenCalled();
   });
 
-  it('clear all sends clearShoppingList (current behaviour, no confirm)', async () => {
+  it('clear all sends clearShoppingList once confirmed', async () => {
     const user = userEvent.setup();
     render(<ForecastPanel spools={spools} />);
     await openShoppingList(user);
     await user.click(screen.getByText('Clear all'));
+
+    const dialog = screen.getByText('Clear shopping list?').closest('div.fixed') as HTMLElement;
+    await user.click(within(dialog).getByRole('button', { name: 'Clear all' }));
+
     await waitFor(() => expect(mocked.clearShoppingList).toHaveBeenCalledTimes(1));
     expect(mocked.removeFromShoppingList).not.toHaveBeenCalled();
   });

+ 26 - 2
frontend/src/__tests__/components/GitHubBackupSettingsWiring.test.tsx

@@ -2,7 +2,8 @@
  * Backup settings wiring: local backup restore/delete target the clicked
  * file and only after the confirm, "Backup Now"/"Run Now" hit their run
  * endpoints, the connection test uses the stored token unless a new one is
- * typed, and Clear empties the backup history.
+ * typed, Clear empties the backup history after a confirm, and a refused
+ * delete says so.
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -206,17 +207,39 @@ describe('GitHubBackupSettings wiring', () => {
     expect(stored).not.toHaveBeenCalled();
   });
 
-  it('Clear in the backup history deletes all logs (keep_last=0)', async () => {
+  it('Clear in the backup history asks first, then deletes all logs (keep_last=0); cancel sends nothing', async () => {
     const user = userEvent.setup();
     render(<GitHubBackupSettings />);
 
     const history = (await screen.findByText('History')).closest('div[id="card-backup-history"]') as HTMLElement;
     await user.click(within(history).getByRole('button', { name: /^clear$/i }));
+    const dialog = () => screen.getByText('Clear backup history?').closest('div.fixed') as HTMLElement;
+    await user.click(within(dialog()).getByRole('button', { name: 'Cancel' }));
+    expect(screen.queryByText('Clear backup history?')).toBeNull();
+    expect(writes()).toEqual([]);
+
+    await user.click(within(history).getByRole('button', { name: /^clear$/i }));
+    await user.click(within(dialog()).getByRole('button', { name: /^clear$/i }));
 
     await waitFor(() => expect(writes()).toHaveLength(1));
     expect(writes()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/github-backup/logs', search: '?keep_last=0' });
   });
 
+  it.each([
+    ['refused (200, success false)', () => HttpResponse.json({ success: false, message: 'Invalid backup file' }), 'Invalid backup file'],
+    ['failed (500)', () => HttpResponse.json({ detail: 'Disk is read-only' }, { status: 500 }), 'Disk is read-only'],
+  ])('a %s local backup delete is reported', async (_label, reply, text) => {
+    server.use(http.delete('*/api/v1/local-backup/backups/:name', reply));
+    const user = userEvent.setup();
+    render(<GitHubBackupSettings />);
+
+    await user.click(within(await waitFor(() => fileRow('bambuddy-backup-2026-10-01.zip'))).getByTitle('Delete'));
+    const confirm = screen.getAllByRole('button', { name: /^delete$/i }).find((b) => !b.hasAttribute('title'));
+    await user.click(confirm!);
+
+    expect(await screen.findByText(text)).toBeInTheDocument();
+  });
+
   it('a non-admin gets no Restore (or Download) button on local backups', async () => {
     setAuthToken('test-token', 'session');
     server.use(
@@ -234,4 +257,5 @@ describe('GitHubBackupSettings wiring', () => {
     );
     expect(within(fileRow('bambuddy-backup-2026-10-01.zip')).queryByTitle('Download')).not.toBeInTheDocument();
   });
+
 });

+ 16 - 3
frontend/src/__tests__/components/LDAPSettingsWiring.test.tsx

@@ -118,15 +118,28 @@ describe('LDAPSettings wiring', () => {
     expect(captured).toHaveLength(0);
   });
 
-  it('test connection posts to /auth/ldap/test (tests the saved config, no body)', async () => {
+  it('test connection posts the form as it is, unsaved edits included', async () => {
     const user = userEvent.setup();
     render(<LDAPSettings />);
 
-    await screen.findByDisplayValue('dc=example');
+    const base = await screen.findByDisplayValue('dc=example');
+    await user.clear(base);
+    await user.type(base, 'ou=people,dc=example');
     await user.click(screen.getByRole('button', { name: /test connection/i }));
 
     await waitFor(() => expect(captured).toHaveLength(1));
-    expect(captured[0]).toEqual({ method: 'POST', path: '/api/v1/auth/ldap/test', body: null });
+    expect(captured[0]).toEqual({
+      method: 'POST',
+      path: '/api/v1/auth/ldap/test',
+      body: {
+        ldap_server_url: 'ldaps://ldap.example:636',
+        ldap_bind_dn: 'cn=svc,dc=example',
+        ldap_bind_password: '',
+        ldap_search_base: 'ou=people,dc=example',
+        ldap_user_filter: '(uid={username})',
+        ldap_security: 'ldaps',
+      },
+    });
   });
 
   it('enable sends only {ldap_enabled:true}', async () => {

+ 9 - 1
frontend/src/__tests__/components/TwoFactorSettingsWiring.test.tsx

@@ -204,12 +204,20 @@ describe('TwoFactorSettings wiring', () => {
     expect(sent('/email/disable')[0].body).toEqual({ password: 'test-password-1' });
   });
 
-  it('unlinking an SSO account deletes the clicked provider link', async () => {
+  it('unlinking an SSO account asks first, then deletes the clicked provider link; cancel sends nothing', async () => {
     const user = userEvent.setup();
     render(<TwoFactorSettings />);
 
     const row = (await screen.findByText('Beta IdP')).closest('div.flex.items-center.justify-between') as HTMLElement;
     await user.click(within(row).getByRole('button'));
+    const dialog = () => screen.getByText('Unlink account?').closest('div.fixed') as HTMLElement;
+    expect(within(dialog()).getByText(/sign in with Beta IdP/)).toBeInTheDocument();
+    await user.click(within(dialog()).getByRole('button', { name: 'Cancel' }));
+    expect(screen.queryByText('Unlink account?')).toBeNull();
+    expect(captured.filter((c) => c.method === 'DELETE')).toEqual([]);
+
+    await user.click(within(row).getByRole('button'));
+    await user.click(within(dialog()).getByRole('button', { name: 'Unlink' }));
 
     await waitFor(() => expect(sent('/oidc/links/8')).toHaveLength(1));
     expect(captured.filter((c) => c.method === 'DELETE')).toEqual([

+ 21 - 0
frontend/src/__tests__/pages/PrintersPageAmsSlotActionsWiring.test.tsx

@@ -446,3 +446,24 @@ describe('AMS unit: backup, drying, friendly name', () => {
     expect(sent).toEqual([]);
   });
 });
+
+describe('AMS slot: Configure dialog', () => {
+  beforeEach(() => {
+    permissions.denied = new Set();
+  });
+
+  // The dialog refuses a slot reset while a print is loaded; it gets the card's state.
+  it.each([
+    ['RUNNING', true],
+    ['IDLE', false],
+  ])('while the printer is %s, Reset Slot disabled: %s', async (state, disabled) => {
+    const user = userEvent.setup();
+    setup({ status: statusWith([amsUnit()], { state }) });
+
+    await user.click(await hover((await slots())[2], 'Configure'));
+
+    const reset = await screen.findByRole('button', { name: /Reset Slot/ });
+    if (disabled) expect(reset).toBeDisabled();
+    else expect(reset).toBeEnabled();
+  });
+});

+ 37 - 8
frontend/src/__tests__/pages/PrintersPageFirmwareModalWiring.test.tsx

@@ -8,10 +8,11 @@
  * the upload.
  */
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, within } from '@testing-library/react';
+import { screen, waitFor, within, act } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { QueryClient } from '@tanstack/react-query';
 
 const permissions = { granted: [] as string[] };
 
@@ -228,15 +229,43 @@ describe('PrintersPage firmware modal wiring', () => {
     expect(versionRow('01.06.00.00')).toBeEnabled();
   });
 
-  // BUG (suspected, needs a product decision): neither the modal nor the
-  // backend prepare/upload routes look at the print state, so firmware can be
-  // pushed over FTP to the SD card of a printer that is mid-print. The audit
-  // item expects the Upload button to be unavailable while printing.
-  it.skip('offers no upload while the printer is printing', async () => {
-    renderPage({ state: 'RUNNING' });
+  // Firmware goes onto the SD card the printer prints from: only while idle,
+  // even when the prepare check (made earlier) still says it can proceed.
+  it.each(['RUNNING', 'PAUSE', 'PREPARE', 'SLICING'])('offers no upload while the printer is %s', async (state) => {
+    renderPage({ state });
     await openModal();
     await waitFor(() => expect(prepareVersions.length).toBeGreaterThan(0));
-    await new Promise((r) => setTimeout(r, 50));
+    expect(await screen.findByText(/can only be uploaded while it is idle/)).toBeInTheDocument();
     expect(screen.queryByRole('button', { name: /Upload Firmware/ })).toBeNull();
+    expect(uploadVersions).toEqual([]);
+  });
+
+  it('asks the prepare check again once a busy printer turns idle, and then offers the upload', async () => {
+    // The page's client: the status reaches the card through its cache, as from the WebSocket.
+    const spy = vi.spyOn(QueryClient.prototype, 'mount');
+    const busy = { ...prepareOk, can_proceed: false, errors: ['The printer is busy; firmware can only be uploaded while it is idle'] };
+    renderPage({ state: 'RUNNING', prepare: busy });
+    await openModal();
+    expect(await screen.findByText(/firmware can only be uploaded while it is idle/)).toBeInTheDocument();
+    const asked = prepareVersions.length;
+
+    server.use(http.get(`/api/v1/firmware/updates/${PRINTER_ID}/prepare`, ({ request }) => {
+      prepareVersions.push(new URL(request.url).searchParams.get('version'));
+      return HttpResponse.json(prepareOk);
+    }));
+    const client = spy.mock.contexts[spy.mock.contexts.length - 1] as QueryClient;
+    act(() => {
+      client.setQueryData(['printerStatus', PRINTER_ID], (old: object | undefined) => ({ ...old, state: 'FINISH' }));
+    });
+
+    expect(await screen.findByRole('button', { name: /Upload Firmware/ })).toBeInTheDocument();
+    expect(prepareVersions.length).toBeGreaterThan(asked);
+    spy.mockRestore();
+  });
+
+  it.each(['FINISH', 'FAILED'])('offers the upload once the printer is %s', async (state) => {
+    renderPage({ state });
+    await openModal();
+    expect(await screen.findByRole('button', { name: /Upload Firmware/ })).toBeInTheDocument();
   });
 });

+ 14 - 0
frontend/src/__tests__/pages/SettingsPageUsersWiring.test.tsx

@@ -162,6 +162,20 @@ describe('SettingsPage users & groups wiring', () => {
     expect(writes()[0].body).not.toHaveProperty('password');
   });
 
+  it('edit user with the email field emptied sends an empty email, which removes it', async () => {
+    const user = userEvent.setup();
+    await openUsersTab();
+
+    const [editBtn] = within(userRow('alice')).getAllByRole('button');
+    await user.click(editBtn);
+    await user.clear(await screen.findByDisplayValue('alice@example.com'));
+    await user.click(screen.getByRole('button', { name: /save changes/i }));
+
+    await waitFor(() => expect(writes()).toHaveLength(1));
+    expect(writes()[0]).toMatchObject({ method: 'PATCH', path: '/api/v1/users/2' });
+    expect(writes()[0].body).toMatchObject({ email: '' });
+  });
+
   it('delete user with items: "delete items" choice sends delete_items=true for that user', async () => {
     const user = userEvent.setup();
     await openUsersTab();

+ 20 - 1
frontend/src/__tests__/pages/SpoolBuddyAmsPageWiring.test.tsx

@@ -17,7 +17,13 @@ vi.mock('../../contexts/AuthContext', async (importOriginal) => {
 });
 vi.mock('../../components/AssignSpoolModal', () => ({ AssignSpoolModal: () => null }));
 vi.mock('../../components/LinkSpoolModal', () => ({ LinkSpoolModal: () => null }));
-vi.mock('../../components/ConfigureAmsSlotModal', () => ({ ConfigureAmsSlotModal: () => null }));
+const configureProps = vi.hoisted(() => [] as Array<Record<string, unknown>>);
+vi.mock('../../components/ConfigureAmsSlotModal', () => ({
+  ConfigureAmsSlotModal: (props: Record<string, unknown>) => {
+    configureProps.push(props);
+    return null;
+  },
+}));
 
 const mutations = {
   unassignSpool: vi.fn(),
@@ -118,6 +124,7 @@ describe('SpoolBuddyAmsPage slot-action wiring', () => {
     mutations.unassignSpoolmanSlot.mockResolvedValue({ success: true });
     mutations.unlinkSpool.mockResolvedValue({ success: true, message: 'ok' });
     spoolmanStatusValue = { enabled: false, connected: false };
+    configureProps.length = 0;
     apiResponses = {
       getPrinterStatus: printerStatus(),
       getPrinter: { id: 3, name: 'P', serial_number: 'SN3', nozzle_count: 1 },
@@ -208,4 +215,16 @@ describe('SpoolBuddyAmsPage slot-action wiring', () => {
     );
     expect(mutations.unassignSpool).not.toHaveBeenCalled();
   });
+
+  // The slot dialog refuses a reset while a print is loaded; it needs the state.
+  it("Configure hands the slot dialog the printer's state", async () => {
+    apiResponses.getPrinterStatus = { ...printerStatus(), state: 'RUNNING' };
+    renderPage();
+    await openSlot(3);
+
+    fireEvent.click(await screen.findByText('Set filament preset, K-profile, and color'));
+
+    await waitFor(() => expect(configureProps.length).toBeGreaterThan(0));
+    expect(configureProps[configureProps.length - 1]).toMatchObject({ printerId: 3, printerState: 'RUNNING' });
+  });
 });

+ 16 - 1
frontend/src/api/client.ts

@@ -4770,6 +4770,9 @@ export interface OIDCLink {
 
 export interface TestSMTPRequest {
   test_recipient: string;
+  // The form's settings, tested before they are saved. An empty password
+  // keeps the saved one.
+  smtp?: SMTPSettings;
 }
 
 export interface TestSMTPResponse {
@@ -4810,6 +4813,15 @@ export interface EncryptionStatus {
   migration_error_count: number;
 }
 
+export interface LDAPTestRequest {
+  ldap_server_url?: string;
+  ldap_bind_dn?: string;
+  ldap_bind_password?: string;
+  ldap_search_base?: string;
+  ldap_user_filter?: string;
+  ldap_security?: string;
+}
+
 export interface LDAPTestResponse {
   success: boolean;
   message: string;
@@ -4908,9 +4920,12 @@ export const api = {
   // LDAP Authentication
   getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),
   getEncryptionStatus: () => request<EncryptionStatus>('/auth/encryption-status'),
-  testLDAP: () =>
+  // form: the settings being edited, tested before they are saved. An empty
+  // bind password keeps the saved one.
+  testLDAP: (form?: LDAPTestRequest) =>
     request<LDAPTestResponse>('/auth/ldap/test', {
       method: 'POST',
+      ...(form ? { body: JSON.stringify(form) } : {}),
     }),
   searchLDAPDirectory: (q: string) =>
     request<LDAPSearchResult[]>(`/auth/ldap/search?q=${encodeURIComponent(q)}`),

+ 29 - 4
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -7,11 +7,13 @@ import type { KProfile } from '../api/client';
 import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex, extractPresetModel } from '../utils/slicerPrinterMatch';
 import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
 import { getAmsLabel } from '../utils/amsHelpers';
 import { getSwatchStyle } from '../utils/colors';
 import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
 import { flowApplies, flowLabel, normaliseFlow } from '../utils/nozzleFlow';
 import { useToast } from '../contexts/ToastContext';
+import { ACTIVE_PRINT_STATES } from '../utils/printer';
 
 interface SlotInfo {
   amsId: number;
@@ -55,6 +57,8 @@ interface ConfigureAmsSlotModalProps {
   printerModel?: string;
   onSuccess?: () => void;
   fullScreen?: boolean;
+  // The printer's gcode state; a reset is refused while a print is loaded.
+  printerState?: string | null;
 }
 
 // Known filament material types
@@ -280,9 +284,13 @@ export function ConfigureAmsSlotModal({
   printerModel,
   onSuccess,
   fullScreen,
+  printerState,
 }: ConfigureAmsSlotModalProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
+  const [showResetConfirm, setShowResetConfirm] = useState(false);
+  // A running print may be feeding from this tray.
+  const printing = ACTIVE_PRINT_STATES.includes(printerState ?? '');
   const [selectedPresetId, setSelectedPresetId] = useState<string>('');
   // The preset the dialog preselected on open, i.e. what the slot is already
   // on. Lets the K-profile list tell "reopened the slot" from "picked another
@@ -1219,10 +1227,11 @@ export function ConfigureAmsSlotModal({
 
   // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
-    if (e.key === 'Escape') {
+    // Escape in the reset confirm closes only the confirm.
+    if (e.key === 'Escape' && !showResetConfirm) {
       onClose();
     }
-  }, [onClose]);
+  }, [onClose, showResetConfirm]);
 
   useEffect(() => {
     if (isOpen) {
@@ -1850,8 +1859,9 @@ export function ConfigureAmsSlotModal({
           {/* Reset button on the left */}
           <Button
             variant="secondary"
-            onClick={() => resetMutation.mutate()}
-            disabled={resetMutation.isPending || configureMutation.isPending}
+            onClick={() => setShowResetConfirm(true)}
+            disabled={printing || resetMutation.isPending || configureMutation.isPending}
+            title={printing ? t('configureAmsSlot.resetBlockedPrinting') : undefined}
             className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10"
           >
             {resetMutation.isPending ? (
@@ -1897,6 +1907,21 @@ export function ConfigureAmsSlotModal({
           </div>
         )}
       </div>
+
+      {showResetConfirm && (
+        <ConfirmModal
+          title={t('configureAmsSlot.resetConfirmTitle')}
+          message={t('configureAmsSlot.resetConfirmMessage')}
+          confirmText={t('configureAmsSlot.resetSlot')}
+          variant="danger"
+          overlayZIndex="z-[60]"
+          onConfirm={() => {
+            setShowResetConfirm(false);
+            resetMutation.mutate();
+          }}
+          onCancel={() => setShowResetConfirm(false)}
+        />
+      )}
     </div>
   );
 }

+ 2 - 0
frontend/src/components/EmailSettings.tsx

@@ -154,8 +154,10 @@ export function EmailSettings() {
       showToast(t('settings.email.errors.enterTestEmail'), 'error');
       return;
     }
+    // What the form holds, saved or not.
     testMutation.mutate({
       test_recipient: testEmail,
+      smtp: smtpSettings,
     });
   };
 

+ 0 - 43
frontend/src/components/FilamentHoverCard.tsx

@@ -24,11 +24,8 @@ interface FilamentData {
 
 interface SpoolmanConfig {
   enabled: boolean;
-  onLinkSpool?: () => void;
-  onUnlinkSpool?: () => void;
   linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
   spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
-  syncMode?: string | null; // If auto-sync is enabled, we may want to hide the unlink option for Bambu spools
 }
 
 interface InventoryConfig {
@@ -89,7 +86,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   // z-index because z-index doesn't cross stacking-context boundaries.
   const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
   const [copied, setCopied] = useState(false);
-  const [showUnlinkConfirm, setShowUnlinkConfirm] = useState(false);
   const triggerRef = useRef<HTMLDivElement>(null);
   const cardRef = useRef<HTMLDivElement>(null);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -581,45 +577,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
         </div>,
         document.body,
       )}
-
-      {/* Unlink Confirmation Dialog */}
-      {showUnlinkConfirm && (
-        <div className="fixed inset-0 z-[100] flex items-center justify-center" onClick={() => setShowUnlinkConfirm(false)}>
-          <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
-          <div
-            className="relative bg-bambu-dark-secondary rounded-lg shadow-xl w-full max-w-sm mx-4 border border-bambu-dark-tertiary"
-            onClick={(e) => e.stopPropagation()}
-          >
-            <div className="p-4 space-y-4">
-              <div className="space-y-2">
-                <h3 className="text-base font-semibold text-white">
-                  {t('spoolman.unlinkConfirmTitle')}
-                </h3>
-                <p className="text-sm text-bambu-gray">
-                  {t('spoolman.unlinkConfirmMessage')}
-                </p>
-              </div>
-              <div className="flex gap-2">
-                <button
-                  onClick={() => setShowUnlinkConfirm(false)}
-                  className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-bambu-dark hover:bg-bambu-dark-tertiary text-white"
-                >
-                  {t('common.cancel')}
-                </button>
-                <button
-                  onClick={() => {
-                    spoolman?.onUnlinkSpool?.();
-                    setShowUnlinkConfirm(false);
-                  }}
-                  className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-100 dark:bg-red-500/20 hover:bg-red-200 dark:hover:bg-red-500/40 text-red-700 dark:text-red-400"
-                >
-                  {t('inventory.unassignSpool')}
-                </button>
-              </div>
-            </div>
-          </div>
-        </div>
-      )}
     </div>
   );
 }

+ 14 - 1
frontend/src/components/ForecastPanel.tsx

@@ -17,6 +17,7 @@ import { getSwatchStyle } from '../utils/colors';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useUiSettings } from '../hooks/useUiSettings';
+import { ConfirmModal } from './ConfirmModal';
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 
@@ -198,6 +199,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
   const [brandFilter, setBrandFilter] = useState('');
   const [cartModal, setCartModal] = useState<SkuForecast | null>(null);
   const [listOpen, setListOpen] = useState(false);
+  const [confirmClearList, setConfirmClearList] = useState(false);
   const [chartDays, setChartDays] = useState<ChartDays>(30);
 
   const { data: settings } = useUiSettings({ enabled: canRead });
@@ -478,11 +480,22 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
               .then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] }))
               .catch(() => showToast(t('forecast.failedSaveSettings'), 'error'));
           }}
-          onClear={() => {
+          onClear={() => setConfirmClearList(true)}
+        />
+      )}
+      {confirmClearList && (
+        <ConfirmModal
+          title={t('forecast.clearAllConfirmTitle')}
+          message={t('forecast.clearAllConfirmMessage', { count: shoppingList.length })}
+          confirmText={t('forecast.clearAll')}
+          variant="danger"
+          onConfirm={() => {
+            setConfirmClearList(false);
             api.clearShoppingList()
               .then(() => queryClient.invalidateQueries({ queryKey: ['shopping-list'] }))
               .catch(() => showToast(t('forecast.failedSaveSettings'), 'error'));
           }}
+          onCancel={() => setConfirmClearList(false)}
         />
       )}
 

+ 23 - 2
frontend/src/components/GitHubBackupSettings.tsx

@@ -184,6 +184,7 @@ export function GitHubBackupSettings() {
 
   // Scheduled local backup state
   const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
+  const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
   const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
   const [localBackupPath, setLocalBackupPath] = useState('');
 
@@ -235,9 +236,15 @@ export function GitHubBackupSettings() {
 
   const deleteLocalBackupMutation = useMutation({
     mutationFn: (filename: string) => api.deleteLocalBackup(filename),
-    onSuccess: () => {
+    onSuccess: (data) => {
       refetchLocalBackups();
       setDeleteConfirmFile(null);
+      // A refused delete answers 200 with success false.
+      if (!data.success) showToast(data.message, 'error');
+    },
+    onError: (error: Error) => {
+      setDeleteConfirmFile(null);
+      showToast(error.message, 'error');
     },
   });
 
@@ -1029,7 +1036,7 @@ export function GitHubBackupSettings() {
                 <Button
                   variant="ghost"
                   size="sm"
-                  onClick={() => clearLogsMutation.mutate()}
+                  onClick={() => setShowClearLogsConfirm(true)}
                   disabled={clearLogsMutation.isPending}
                 >
                   <Trash2 className="w-4 h-4" />
@@ -1500,6 +1507,20 @@ export function GitHubBackupSettings() {
       {/* Restore from the Git backup repository (#2656) */}
       {showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
 
+      {showClearLogsConfirm && (
+        <ConfirmModal
+          title={t('backup.clearHistoryConfirmTitle')}
+          message={t('backup.clearHistoryConfirmMessage')}
+          confirmText={t('backup.clear')}
+          variant="danger"
+          onConfirm={() => {
+            setShowClearLogsConfirm(false);
+            clearLogsMutation.mutate();
+          }}
+          onCancel={() => setShowClearLogsConfirm(false)}
+        />
+      )}
+
       {/* Delete Backup Confirmation Modal */}
       {deleteConfirmFile && (
         <ConfirmModal

+ 10 - 1
frontend/src/components/LDAPSettings.tsx

@@ -113,7 +113,16 @@ export function LDAPSettings() {
 
   // Test connection
   const testMutation = useMutation({
-    mutationFn: () => api.testLDAP(),
+    // What the form holds, saved or not.
+    mutationFn: () =>
+      api.testLDAP({
+        ldap_server_url: form.ldap_server_url,
+        ldap_bind_dn: form.ldap_bind_dn,
+        ldap_bind_password: form.ldap_bind_password,
+        ldap_search_base: form.ldap_search_base,
+        ldap_user_filter: form.ldap_user_filter,
+        ldap_security: form.ldap_security,
+      }),
     onSuccess: (data: { success: boolean; message: string }) => {
       showToast(data.message, data.success ? 'success' : 'error');
     },

+ 17 - 1
frontend/src/components/TwoFactorSettings.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 
@@ -182,6 +183,7 @@ export function TwoFactorSettings() {
   const [disableCode, setDisableCode] = useState('');
   const [regenCode, setRegenCode] = useState('');
   const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);
+  const [unlinkTarget, setUnlinkTarget] = useState<{ providerId: number; providerName: string } | null>(null);
 
   // Email OTP enable: two-step proof-of-possession flow
   const [emailSetupToken, setEmailSetupToken] = useState<string | null>(null);
@@ -531,7 +533,7 @@ export function TwoFactorSettings() {
                   <Button
                     variant="danger"
                     size="sm"
-                    onClick={() => unlinkOIDCMutation.mutate(link.provider_id)}
+                    onClick={() => setUnlinkTarget({ providerId: link.provider_id, providerName: link.provider_name })}
                     disabled={unlinkOIDCMutation.isPending}
                   >
                     <Trash2 className="w-4 h-4" />
@@ -542,6 +544,20 @@ export function TwoFactorSettings() {
           </CardContent>
         </Card>
       )}
+
+      {unlinkTarget && (
+        <ConfirmModal
+          title={t('settings.twoFa.oidcUnlinkConfirmTitle')}
+          message={t('settings.twoFa.oidcUnlinkConfirmMessage', { provider: unlinkTarget.providerName })}
+          confirmText={t('settings.twoFa.oidcUnlinkConfirmAction')}
+          variant="danger"
+          onConfirm={() => {
+            unlinkOIDCMutation.mutate(unlinkTarget.providerId);
+            setUnlinkTarget(null);
+          }}
+          onCancel={() => setUnlinkTarget(null)}
+        />
+      )}
     </div>
   );
 }

+ 11 - 2
frontend/src/i18n/locales/de.ts

@@ -940,6 +940,7 @@ export default {
       done: 'Fertig',
       starting: 'Starte...',
       uploadFirmware: 'Firmware hochladen',
+      printerBusy: 'Der Drucker ist beschäftigt. Firmware kann nur hochgeladen werden, wenn er im Leerlauf ist.',
       uploadFailed: 'Upload fehlgeschlagen: {{error}}',
       uploadedToast: 'Firmware hochgeladen! Starten Sie das Update vom Druckerbildschirm.',
       availableVersions: 'Verfügbare Versionen',
@@ -3044,6 +3045,9 @@ export default {
       linkedAccounts: 'Verknüpfte SSO-Konten',
       linkedAccountsDesc: 'Diese externen Identitätsanbieter sind mit deinem Konto verknüpft.',
       oidcUnlinked: 'Konto getrennt.',
+      oidcUnlinkConfirmTitle: 'Konto trennen?',
+      oidcUnlinkConfirmMessage: 'Du kannst dich danach nicht mehr mit {{provider}} anmelden.',
+      oidcUnlinkConfirmAction: 'Trennen',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4913,8 +4917,6 @@ export default {
     linkToSpoolman: 'Mit Spoolman verknüpfen',
     openInSpoolman: 'In Spoolman öffnen',
     unlinkSpool: 'Spule trennen',
-    unlinkConfirmTitle: 'Spule entfernen?',
-    unlinkConfirmMessage: 'Die Spule wird vom Slot entfernt. Die Spulendaten selbst bleiben unverändert.',
     selectSpool: 'Spule auswählen',
     noUnlinkedSpools: 'Keine nicht zugewiesenen Spulen verfügbar',
     linkSuccess: 'Spule erfolgreich zugewiesen',
@@ -5670,6 +5672,8 @@ export default {
     backupSkippedNoChanges: 'Sicherung übersprungen - keine Änderungen',
     backupFailed2: 'Sicherung fehlgeschlagen: {{message}}',
     clearedLogs: '{{count}} Protokolle gelöscht',
+    clearHistoryConfirmTitle: 'Backup-Verlauf leeren?',
+    clearHistoryConfirmMessage: 'Dadurch werden alle Einträge aus dem Backup-Verlauf entfernt. Die Backups selbst bleiben erhalten.',
     failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7169,6 +7173,9 @@ export default {
     hexLabel: 'Hex – #{{hex}}',
     resetting: 'Wird zurückgesetzt...',
     resetSlot: 'Slot zurücksetzen',
+    resetConfirmTitle: 'Slot zurücksetzen?',
+    resetConfirmMessage: 'Dadurch wird die Filament-Einstellung dieses Slots am Drucker gelöscht.',
+    resetBlockedPrinting: 'Nicht verfügbar, während der Drucker druckt',
     cancel: 'Abbrechen',
     configuring: 'Wird konfiguriert...',
     configureSlot: 'Slot konfigurieren',
@@ -8255,6 +8262,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Einkaufsliste leeren?',
+    clearAllConfirmMessage: 'Dadurch werden alle {{count}} Einträge von der Einkaufsliste entfernt.',
     title: 'Bestandsprognose',
     noSpools: 'Keine aktiven Spulen gefunden. Fügen Sie Spulen zu Ihrem Inventar hinzu, um Prognosedaten zu sehen.',
     noUsageData: 'Keine Verbrauchsdaten verfügbar — die Bestandszeitlinie kann nicht projiziert werden.',

+ 11 - 2
frontend/src/i18n/locales/en.ts

@@ -947,6 +947,7 @@ export default {
       done: 'Done',
       starting: 'Starting...',
       uploadFirmware: 'Upload Firmware',
+      printerBusy: 'The printer is busy. Firmware can only be uploaded while it is idle.',
       uploadFailed: 'Failed to start upload: {{error}}',
       uploadedToast: 'Firmware uploaded! Trigger update from printer screen.',
       availableVersions: 'Available versions',
@@ -3066,6 +3067,9 @@ export default {
       linkedAccounts: 'Linked SSO Accounts',
       linkedAccountsDesc: 'These external identity providers are linked to your account.',
       oidcUnlinked: 'Account unlinked.',
+      oidcUnlinkConfirmTitle: 'Unlink account?',
+      oidcUnlinkConfirmMessage: 'You will no longer be able to sign in with {{provider}}.',
+      oidcUnlinkConfirmAction: 'Unlink',
     },
 
     // Session Policy (#1706) — admin-configurable session lifetime ceiling.
@@ -4950,8 +4954,6 @@ export default {
     linkToSpoolman: 'Link to Spoolman',
     openInSpoolman: 'Open in Spoolman',
     unlinkSpool: 'Unlink Spool',
-    unlinkConfirmTitle: 'Unassign Spool?',
-    unlinkConfirmMessage: 'This will remove the spool from this slot. The spool data itself will remain unchanged.',
     selectSpool: 'Select Spool',
     noUnlinkedSpools: 'No unassigned spools available',
     linkSuccess: 'Spool assigned successfully',
@@ -5717,6 +5719,8 @@ export default {
     backupSkippedNoChanges: 'Backup skipped - no changes',
     backupFailed2: 'Backup failed: {{message}}',
     clearedLogs: 'Cleared {{count}} logs',
+    clearHistoryConfirmTitle: 'Clear backup history?',
+    clearHistoryConfirmMessage: 'This removes every entry from the backup history. The backups themselves are kept.',
     failedToClearLogs: 'Failed to clear logs: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7222,6 +7226,9 @@ export default {
     hexLabel: 'Hex: #{{hex}}',
     resetting: 'Resetting...',
     resetSlot: 'Reset Slot',
+    resetConfirmTitle: 'Reset slot?',
+    resetConfirmMessage: 'This clears the filament setting of this slot on the printer.',
+    resetBlockedPrinting: 'Not available while the printer is printing',
     cancel: 'Cancel',
     configuring: 'Configuring...',
     configureSlot: 'Configure Slot',
@@ -8307,6 +8314,8 @@ export default {
   },
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Clear shopping list?',
+    clearAllConfirmMessage: 'This removes all {{count}} item(s) from the shopping list.',
     title: 'Forecast',
     noSpools: 'No active spools found. Add spools to your inventory to see forecast data.',
     noUsageData: 'No usage data available — cannot project stock timeline.',

+ 11 - 2
frontend/src/i18n/locales/es.ts

@@ -940,6 +940,7 @@ export default {
       done: 'Hecho',
       starting: 'Iniciando...',
       uploadFirmware: 'Subir firmware',
+      printerBusy: 'La impresora está ocupada. El firmware solo se puede subir cuando está inactiva.',
       uploadFailed: 'Error al iniciar la subida: {{error}}',
       uploadedToast: '¡Firmware subido! Inicie la actualización desde la pantalla de la impresora.',
       availableVersions: 'Versiones disponibles',
@@ -3046,6 +3047,9 @@ export default {
       linkedAccounts: 'Cuentas de SSO vinculadas',
       linkedAccountsDesc: 'Estos proveedores de identidad externos están vinculados a su cuenta.',
       oidcUnlinked: 'Cuenta desvinculada.',
+      oidcUnlinkConfirmTitle: '¿Desvincular cuenta?',
+      oidcUnlinkConfirmMessage: 'Ya no podrás iniciar sesión con {{provider}}.',
+      oidcUnlinkConfirmAction: 'Desvincular',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4915,8 +4919,6 @@ export default {
     linkToSpoolman: 'Vincular a Spoolman',
     openInSpoolman: 'Abrir en Spoolman',
     unlinkSpool: 'Desvincular bobina',
-    unlinkConfirmTitle: '¿Desasignar la bobina?',
-    unlinkConfirmMessage: 'Esto quitará la bobina de esta ranura. Los datos de la bobina en sí permanecerán sin cambios.',
     selectSpool: 'Seleccionar bobina',
     noUnlinkedSpools: 'No hay bobinas sin asignar disponibles',
     linkSuccess: 'Bobina asignada correctamente',
@@ -5677,6 +5679,8 @@ export default {
     backupSkippedNoChanges: 'Copia de seguridad omitida - sin cambios',
     backupFailed2: 'Error en la copia de seguridad: {{message}}',
     clearedLogs: 'Se borraron {{count}} registros',
+    clearHistoryConfirmTitle: '¿Borrar historial de copias?',
+    clearHistoryConfirmMessage: 'Esto elimina todas las entradas del historial de copias de seguridad. Las copias se conservan.',
     failedToClearLogs: 'Error al borrar los registros: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7177,6 +7181,9 @@ export default {
     hexLabel: 'Hex: #{{hex}}',
     resetting: 'Restableciendo...',
     resetSlot: 'Restablecer ranura',
+    resetConfirmTitle: '¿Restablecer ranura?',
+    resetConfirmMessage: 'Esto borra la configuración de filamento de esta ranura en la impresora.',
+    resetBlockedPrinting: 'No disponible mientras la impresora está imprimiendo',
     cancel: 'Cancelar',
     configuring: 'Configurando...',
     configureSlot: 'Configurar ranura',
@@ -8262,6 +8269,8 @@ export default {
   },
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: '¿Vaciar lista de compras?',
+    clearAllConfirmMessage: 'Esto elimina los {{count}} elementos de la lista de compras.',
     title: 'Previsión',
     noSpools: 'No se encontraron bobinas activas. Añada bobinas a su inventario para ver los datos de previsión.',
     noUsageData: 'No hay datos de uso disponibles — no se puede proyectar la cronología de existencias.',

+ 11 - 2
frontend/src/i18n/locales/fr.ts

@@ -940,6 +940,7 @@ export default {
       done: 'Terminé',
       starting: 'Démarrage...',
       uploadFirmware: 'Téléverser le Firmware',
+      printerBusy: 'L\'imprimante est occupée. Le firmware ne peut être envoyé que lorsqu\'elle est inactive.',
       uploadFailed: 'Échec du téléversement : {{error}}',
       uploadedToast: 'Firmware téléversé ! Lancez la mise à jour sur l\'écran.',
       availableVersions: 'Versions disponibles',
@@ -2986,6 +2987,9 @@ export default {
       linkedAccounts: 'Comptes SSO liés',
       linkedAccountsDesc: 'Ces fournisseurs d\'identité externes sont liés à votre compte.',
       oidcUnlinked: 'Compte dissocié.',
+      oidcUnlinkConfirmTitle: 'Dissocier le compte ?',
+      oidcUnlinkConfirmMessage: 'Vous ne pourrez plus vous connecter avec {{provider}}.',
+      oidcUnlinkConfirmAction: 'Dissocier',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4901,8 +4905,6 @@ export default {
     linkToSpoolman: 'Lier à Spoolman',
     openInSpoolman: 'Ouvrir Spoolman',
     unlinkSpool: 'Délier bobine',
-    unlinkConfirmTitle: 'Désassigner la bobine ?',
-    unlinkConfirmMessage: 'Cela retirera la bobine de cet emplacement. Les données de la bobine elles-mêmes resteront inchangées.',
     selectSpool: 'Choisir bobine',
     noUnlinkedSpools: 'Pas de bobine libre',
     linkSuccess: 'Bobine assignée avec succès',
@@ -5657,6 +5659,8 @@ export default {
     backupSkippedNoChanges: 'Sauvegarde ignorée - aucun changement',
     backupFailed2: 'Échec de la sauvegarde : {{message}}',
     clearedLogs: '{{count}} journaux supprimés',
+    clearHistoryConfirmTitle: 'Effacer l\'historique des sauvegardes ?',
+    clearHistoryConfirmMessage: 'Cela supprime toutes les entrées de l\'historique des sauvegardes. Les sauvegardes elles-mêmes sont conservées.',
     failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7157,6 +7161,9 @@ export default {
     hexLabel: 'Hex : #{{hex}}',
     resetting: 'Réinitialisation...',
     resetSlot: 'Réinitialiser le slot',
+    resetConfirmTitle: 'Réinitialiser l\'emplacement ?',
+    resetConfirmMessage: 'Cela efface le réglage de filament de cet emplacement sur l\'imprimante.',
+    resetBlockedPrinting: 'Indisponible pendant que l\'imprimante imprime',
     cancel: 'Annuler',
     configuring: 'Configuration...',
     configureSlot: 'Configurer le slot',
@@ -8241,6 +8248,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Vider la liste de courses ?',
+    clearAllConfirmMessage: 'Cela retire les {{count}} article(s) de la liste de courses.',
     title: 'Prévision des stocks',
     noSpools: 'Aucune bobine active trouvée. Ajoutez des bobines à votre inventaire pour voir les données de prévision.',
     noUsageData: 'Aucune donnée d\'utilisation disponible — impossible de projeter la chronologie des stocks.',

+ 11 - 2
frontend/src/i18n/locales/it.ts

@@ -940,6 +940,7 @@ export default {
       done: 'Fatto',
       starting: 'Avvio...',
       uploadFirmware: 'Carica Firmware',
+      printerBusy: 'La stampante è occupata. Il firmware può essere caricato solo quando è inattiva.',
       uploadFailed: 'Avvio caricamento fallito: {{error}}',
       uploadedToast: 'Firmware caricato! Avvia aggiornamento dal display.',
       availableVersions: 'Versioni disponibili',
@@ -2985,6 +2986,9 @@ export default {
       linkedAccounts: 'Account SSO collegati',
       linkedAccountsDesc: 'Questi provider di identità esterni sono collegati al tuo account.',
       oidcUnlinked: 'Account scollegato.',
+      oidcUnlinkConfirmTitle: 'Scollegare l\'account?',
+      oidcUnlinkConfirmMessage: 'Non potrai più accedere con {{provider}}.',
+      oidcUnlinkConfirmAction: 'Scollega',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4900,8 +4904,6 @@ export default {
     linkToSpoolman: 'Collega a Spoolman',
     openInSpoolman: 'Apri in Spoolman',
     unlinkSpool: 'Scollega bobina',
-    unlinkConfirmTitle: 'Rimuovere bobina?',
-    unlinkConfirmMessage: 'Questo rimuoverà la bobina da questo slot. I dati della bobina stessa rimarranno invariati.',
     selectSpool: 'Seleziona bobina',
     noUnlinkedSpools: 'Nessuna bobina disponibile',
     linkSuccess: 'Bobina assegnata con successo',
@@ -5656,6 +5658,8 @@ export default {
     backupSkippedNoChanges: 'Backup saltato - nessuna modifica',
     backupFailed2: 'Backup fallito: {{message}}',
     clearedLogs: '{{count}} log eliminati',
+    clearHistoryConfirmTitle: 'Cancellare la cronologia dei backup?',
+    clearHistoryConfirmMessage: 'Questo rimuove tutte le voci dalla cronologia dei backup. I backup stessi vengono mantenuti.',
     failedToClearLogs: 'Eliminazione log fallita: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7156,6 +7160,9 @@ export default {
     hexLabel: 'Hex – #{{hex}}',
     resetting: 'Ripristino...',
     resetSlot: 'Ripristina slot',
+    resetConfirmTitle: 'Reimpostare lo slot?',
+    resetConfirmMessage: 'Questo cancella l\'impostazione del filamento di questo slot sulla stampante.',
+    resetBlockedPrinting: 'Non disponibile mentre la stampante sta stampando',
     cancel: 'Annulla',
     configuring: 'Configurazione...',
     configureSlot: 'Configura slot',
@@ -8240,6 +8247,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Svuotare la lista della spesa?',
+    clearAllConfirmMessage: 'Questo rimuove tutti i {{count}} elementi dalla lista della spesa.',
     title: 'Previsione scorte',
     noSpools: 'Nessuna bobina attiva trovata. Aggiungi bobine al tuo inventario per vedere i dati di previsione.',
     noUsageData: 'Nessun dato di utilizzo disponibile — impossibile proiettare la timeline delle scorte.',

+ 11 - 2
frontend/src/i18n/locales/ja.ts

@@ -939,6 +939,7 @@ export default {
       done: '完了',
       starting: '開始中...',
       uploadFirmware: 'ファームウェアをアップロード',
+      printerBusy: 'プリンターは使用中です。ファームウェアはアイドル状態のときのみアップロードできます。',
       uploadFailed: 'アップロード開始に失敗しました: {{error}}',
       uploadedToast: 'ファームウェアをアップロードしました!プリンター画面からアップデートを実行してください。',
       availableVersions: '利用可能なバージョン',
@@ -3043,6 +3044,9 @@ export default {
       linkedAccounts: 'リンクされたSSOアカウント',
       linkedAccountsDesc: 'これらの外部IDプロバイダーがあなたのアカウントにリンクされています。',
       oidcUnlinked: 'アカウントのリンクを解除しました。',
+      oidcUnlinkConfirmTitle: 'アカウントのリンクを解除しますか?',
+      oidcUnlinkConfirmMessage: '{{provider}} でサインインできなくなります。',
+      oidcUnlinkConfirmAction: 'リンク解除',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4913,8 +4917,6 @@ export default {
     linkToSpoolman: 'Spoolmanに連携',
     openInSpoolman: 'Spoolmanで開く',
     unlinkSpool: 'スプールのリンクを解除',
-    unlinkConfirmTitle: 'スプールの割り当てを解除しますか?',
-    unlinkConfirmMessage: 'このスロットからスプールが削除されます。スプール自体のデータは変更されません。',
     selectSpool: 'スプールを選択',
     noUnlinkedSpools: '割り当てられていないスプールがありません',
     linkSuccess: 'スプールが正常に割り当てられました',
@@ -5670,6 +5672,8 @@ export default {
     backupSkippedNoChanges: 'バックアップをスキップ - 変更なし',
     backupFailed2: 'バックアップに失敗しました: {{message}}',
     clearedLogs: '{{count}}件のログを削除しました',
+    clearHistoryConfirmTitle: 'バックアップ履歴を消去しますか?',
+    clearHistoryConfirmMessage: 'バックアップ履歴のすべての項目を削除します。バックアップ自体は保持されます。',
     failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7170,6 +7174,9 @@ export default {
     hexLabel: '16進: #{{hex}}',
     resetting: 'リセット中...',
     resetSlot: 'スロットをリセット',
+    resetConfirmTitle: 'スロットをリセットしますか?',
+    resetConfirmMessage: 'プリンター上のこのスロットのフィラメント設定を消去します。',
+    resetBlockedPrinting: 'プリンターの印刷中は使用できません',
     cancel: 'キャンセル',
     configuring: '設定中...',
     configureSlot: 'スロットを設定',
@@ -8254,6 +8261,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: '買い物リストを空にしますか?',
+    clearAllConfirmMessage: '買い物リストから {{count}} 件すべてを削除します。',
     title: '在庫予測',
     noSpools: 'アクティブなスプールが見つかりません。予測データを表示するには、在庫にスプールを追加してください。',
     noUsageData: '使用データがありません — 在庫のタイムラインを予測できません。',

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

@@ -893,6 +893,7 @@ export default {
       done: '완료',
       starting: '시작 중...',
       uploadFirmware: '펌웨어 업로드',
+      printerBusy: '프린터가 사용 중입니다. 펌웨어는 유휴 상태일 때만 업로드할 수 있습니다.',
       uploadFailed: '업로드 시작 실패: {{error}}',
       uploadedToast: '펌웨어 업로드됨! 프린터 화면에서 업데이트를 시작하세요.',
       availableVersions: '사용 가능한 버전',
@@ -2894,7 +2895,10 @@ export default {
       newBackupCodes: '새 백업 코드',
       linkedAccounts: '연결된 SSO 계정',
       linkedAccountsDesc: '이 외부 ID 제공자가 계정에 연결되어 있습니다.',
-      oidcUnlinked: '계정 연결이 해제되었습니다.'
+      oidcUnlinked: '계정 연결이 해제되었습니다.',
+      oidcUnlinkConfirmTitle: '계정 연결을 해제할까요?',
+      oidcUnlinkConfirmMessage: '더 이상 {{provider}}(으)로 로그인할 수 없습니다.',
+      oidcUnlinkConfirmAction: '연결 해제',
     },
     // 세션 정책 (#1706)
     sessionPolicy: {
@@ -4703,8 +4707,6 @@ export default {
     linkToSpoolman: 'Spoolman에 연결',
     openInSpoolman: 'Spoolman에서 열기',
     unlinkSpool: '스풀 연결 해제',
-    unlinkConfirmTitle: '스풀 할당 해제?',
-    unlinkConfirmMessage: '이 슬롯에서 스풀을 제거합니다. 스풀 데이터 자체는 변경되지 않습니다.',
     selectSpool: '스풀 선택',
     noUnlinkedSpools: '할당되지 않은 스풀 없음',
     linkSuccess: '스풀이 성공적으로 할당되었습니다',
@@ -5433,6 +5435,8 @@ export default {
     backupSkippedNoChanges: '변경 사항 없어 백업 건너뜀',
     backupFailed2: '백업 실패: {{message}}',
     clearedLogs: '{{count}}개 로그 초기화됨',
+    clearHistoryConfirmTitle: '백업 기록을 지울까요?',
+    clearHistoryConfirmMessage: '백업 기록의 모든 항목을 삭제합니다. 백업 자체는 유지됩니다.',
     failedToClearLogs: '로그 초기화 실패: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -6857,6 +6861,9 @@ export default {
     hexLabel: '16진수: #{{hex}}',
     resetting: '초기화 중...',
     resetSlot: '슬롯 초기화',
+    resetConfirmTitle: '슬롯을 초기화할까요?',
+    resetConfirmMessage: '프린터에서 이 슬롯의 필라멘트 설정을 지웁니다.',
+    resetBlockedPrinting: '프린터가 출력 중일 때는 사용할 수 없습니다',
     cancel: '취소',
     configuring: '구성 중...',
     configureSlot: '슬롯 구성',
@@ -7694,6 +7701,8 @@ export default {
     }
   },
   forecast: {
+    clearAllConfirmTitle: '쇼핑 목록을 비울까요?',
+    clearAllConfirmMessage: '쇼핑 목록에서 {{count}}개 항목을 모두 삭제합니다.',
     title: '예측',
     noSpools: '활성 스풀을 찾을 수 없습니다. 예측 데이터를 보려면 재고에 스풀을 추가하세요.',
     noUsageData: '사용 데이터를 사용할 수 없습니다 — 재고 타임라인을 예측할 수 없습니다.',

+ 11 - 2
frontend/src/i18n/locales/nl.ts

@@ -945,6 +945,7 @@ export default {
       done: 'Klaar',
       starting: 'Starten...',
       uploadFirmware: 'Firmware uploaden',
+      printerBusy: 'De printer is bezig. Firmware kan alleen worden geüpload als hij inactief is.',
       uploadFailed: 'Upload starten mislukt: {{error}}',
       uploadedToast: 'Firmware geüpload! Start de update vanaf het printerscherm.',
       availableVersions: 'Beschikbare versies',
@@ -3064,6 +3065,9 @@ export default {
       linkedAccounts: 'Gekoppelde SSO-accounts',
       linkedAccountsDesc: 'Deze externe identiteitsproviders zijn gekoppeld aan je account.',
       oidcUnlinked: 'Account ontkoppeld.',
+      oidcUnlinkConfirmTitle: 'Account ontkoppelen?',
+      oidcUnlinkConfirmMessage: 'Je kunt dan niet meer inloggen met {{provider}}.',
+      oidcUnlinkConfirmAction: 'Ontkoppelen',
     },
 
     // Session Policy (#1706) — admin-configurable session lifetime ceiling.
@@ -4948,8 +4952,6 @@ export default {
     linkToSpoolman: 'Koppelen aan Spoolman',
     openInSpoolman: 'Openen in Spoolman',
     unlinkSpool: 'Spoel ontkoppelen',
-    unlinkConfirmTitle: 'Spoeltoewijzing verwijderen?',
-    unlinkConfirmMessage: 'Hiermee wordt de spoel uit deze sleuf verwijderd. De spoelgegevens zelf blijven ongewijzigd.',
     selectSpool: 'Spoel selecteren',
     noUnlinkedSpools: 'Geen niet-toegewezen spoelen beschikbaar',
     linkSuccess: 'Spoel succesvol toegewezen',
@@ -5715,6 +5717,8 @@ export default {
     backupSkippedNoChanges: 'Back-up overgeslagen - geen wijzigingen',
     backupFailed2: 'Back-up mislukt: {{message}}',
     clearedLogs: '{{count}} logboeken gewist',
+    clearHistoryConfirmTitle: 'Back-upgeschiedenis wissen?',
+    clearHistoryConfirmMessage: 'Dit verwijdert alle items uit de back-upgeschiedenis. De back-ups zelf blijven bewaard.',
     failedToClearLogs: 'Logboeken wissen mislukt: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7220,6 +7224,9 @@ export default {
     hexLabel: 'Hex: #{{hex}}',
     resetting: 'Resetten...',
     resetSlot: 'Slot resetten',
+    resetConfirmTitle: 'Slot resetten?',
+    resetConfirmMessage: 'Dit wist de filamentinstelling van dit slot op de printer.',
+    resetBlockedPrinting: 'Niet beschikbaar terwijl de printer print',
     cancel: 'Annuleren',
     configuring: 'Configureren...',
     configureSlot: 'Sleuf configureren',
@@ -8305,6 +8312,8 @@ export default {
   },
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Boodschappenlijst leegmaken?',
+    clearAllConfirmMessage: 'Dit verwijdert alle {{count}} item(s) van de boodschappenlijst.',
     title: 'Prognose',
     noSpools: 'Geen actieve spoelen gevonden. Voeg spoelen toe aan je voorraad om prognosegegevens te zien.',
     noUsageData: 'Geen verbruiksgegevens beschikbaar — voorraadverloop kan niet worden voorspeld.',

+ 11 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -940,6 +940,7 @@ export default {
       done: 'Concluído',
       starting: 'Iniciando...',
       uploadFirmware: 'Enviar Firmware',
+      printerBusy: 'A impressora está ocupada. O firmware só pode ser enviado quando ela estiver ociosa.',
       uploadFailed: 'Falha ao iniciar o envio: {{error}}',
       uploadedToast: 'Firmware enviado! Inicie a atualização na tela da impressora.',
       availableVersions: 'Versões disponíveis',
@@ -2985,6 +2986,9 @@ export default {
       linkedAccounts: 'Contas SSO vinculadas',
       linkedAccountsDesc: 'Estes provedores de identidade externos estão vinculados à sua conta.',
       oidcUnlinked: 'Conta desvinculada.',
+      oidcUnlinkConfirmTitle: 'Desvincular conta?',
+      oidcUnlinkConfirmMessage: 'Você não poderá mais entrar com {{provider}}.',
+      oidcUnlinkConfirmAction: 'Desvincular',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4900,8 +4904,6 @@ export default {
     linkToSpoolman: 'Vincular ao Spoolman',
     openInSpoolman: 'Abrir no Spoolman',
     unlinkSpool: 'Desvincular Carretel',
-    unlinkConfirmTitle: 'Remover atribuição do carretel?',
-    unlinkConfirmMessage: 'Isso removerá o carretel deste slot. Os dados do próprio carretel permanecerão inalterados.',
     selectSpool: 'Selecionar Carretel',
     noUnlinkedSpools: 'Nenhum carretel não atribuído disponível',
     linkSuccess: 'Carretel atribuído com sucesso',
@@ -5656,6 +5658,8 @@ export default {
     backupSkippedNoChanges: 'Backup ignorado - sem alterações',
     backupFailed2: 'Falha no backup: {{message}}',
     clearedLogs: '{{count}} logs removidos',
+    clearHistoryConfirmTitle: 'Limpar histórico de backups?',
+    clearHistoryConfirmMessage: 'Isso remove todas as entradas do histórico de backups. Os backups em si são mantidos.',
     failedToClearLogs: 'Falha ao limpar logs: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7156,6 +7160,9 @@ export default {
     hexLabel: 'Hex – #{{hex}}',
     resetting: 'Redefinindo...',
     resetSlot: 'Redefinir Slot',
+    resetConfirmTitle: 'Redefinir slot?',
+    resetConfirmMessage: 'Isso apaga a configuração de filamento deste slot na impressora.',
+    resetBlockedPrinting: 'Indisponível enquanto a impressora está imprimindo',
     cancel: 'Cancelar',
     configuring: 'Configurando...',
     configureSlot: 'Configurar Slot',
@@ -8240,6 +8247,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Limpar lista de compras?',
+    clearAllConfirmMessage: 'Isso remove todos os {{count}} itens da lista de compras.',
     title: 'Previsão de Estoque',
     noSpools: 'Nenhum carretel ativo encontrado. Adicione carretéis ao seu inventário para ver os dados de previsão.',
     noUsageData: 'Nenhum dado de uso disponível — não é possível projetar a linha do tempo do estoque.',

+ 11 - 2
frontend/src/i18n/locales/ru.ts

@@ -898,6 +898,7 @@ export default {
       done: "Готово",
       starting: "Запуск...",
       uploadFirmware: "Загрузить прошивку",
+      printerBusy: "Принтер занят. Прошивку можно загрузить только в режиме простоя.",
       uploadFailed: "Не удалось начать загрузку: {{error}}",
       uploadedToast: "Прошивка загружена. Запустите обновление с экрана принтера.",
       availableVersions: "Доступные версии",
@@ -2899,6 +2900,9 @@ export default {
       linkedAccounts: "Связанные учётные записи SSO",
       linkedAccountsDesc: "Эти внешние провайдеры идентификации связаны с вашей учётной записью.",
       oidcUnlinked: "Учётная запись отсоединена.",
+      oidcUnlinkConfirmTitle: "Отвязать аккаунт?",
+      oidcUnlinkConfirmMessage: "Вы больше не сможете входить через {{provider}}.",
+      oidcUnlinkConfirmAction: "Отвязать",
     },
     sessionPolicy: {
       title: "Политика сеансов",
@@ -4694,8 +4698,6 @@ export default {
     linkToSpoolman: "Связать со Spoolman",
     openInSpoolman: "Открыть в Spoolman",
     unlinkSpool: "Отвязать катушку",
-    unlinkConfirmTitle: "Снять назначение катушки?",
-    unlinkConfirmMessage: "Катушка будет удалена из этого слота. Данные самой катушки останутся без изменений.",
     selectSpool: "Выбрать катушку",
     noUnlinkedSpools: "Нет свободных катушек",
     linkSuccess: "Катушка успешно назначена",
@@ -5425,6 +5427,8 @@ export default {
     backupSkippedNoChanges: "Резервное копирование пропущено — изменений нет",
     backupFailed2: "Ошибка резервного копирования: {{message}}",
     clearedLogs: "Очищено записей журнала: {{count}}",
+    clearHistoryConfirmTitle: "Очистить историю резервных копий?",
+    clearHistoryConfirmMessage: "Все записи истории резервного копирования будут удалены. Сами резервные копии сохранятся.",
     failedToClearLogs: "Не удалось очистить журнал: {{message}}",
 
     // Restore from Git backup (#2656)
@@ -6844,6 +6848,9 @@ export default {
     hexLabel: "HEX: #{{hex}}",
     resetting: "Сброс...",
     resetSlot: "Сбросить слот",
+    resetConfirmTitle: "Сбросить слот?",
+    resetConfirmMessage: "Настройка филамента этого слота на принтере будет удалена.",
+    resetBlockedPrinting: "Недоступно во время печати",
     cancel: "Отмена",
     configuring: "Настройка...",
     configureSlot: "Настроить слот",
@@ -7869,6 +7876,8 @@ export default {
     },
   },
   forecast: {
+    clearAllConfirmTitle: "Очистить список покупок?",
+    clearAllConfirmMessage: "Все позиции ({{count}}) будут удалены из списка покупок.",
     title: "Прогноз",
     noSpools: "Активные катушки не найдены. Добавьте катушки в учёт, чтобы увидеть прогноз.",
     noUsageData: "Нет данных о расходе — невозможно спрогнозировать остатки.",

+ 11 - 2
frontend/src/i18n/locales/sv.ts

@@ -945,6 +945,7 @@ export default {
       done: 'Klar',
       starting: 'Startar...',
       uploadFirmware: 'Ladda upp firmware',
+      printerBusy: 'Skrivaren är upptagen. Firmware kan bara laddas upp när den är inaktiv.',
       uploadFailed: 'Kunde inte starta uppladdning: {{error}}',
       uploadedToast: 'Firmware uppladdat! Trigga uppdatering från skrivarens skärm.',
       availableVersions: 'Tillgängliga versioner',
@@ -3063,6 +3064,9 @@ export default {
       linkedAccounts: 'Länkade SSO-konton',
       linkedAccountsDesc: 'Dessa externa identitetsleverantörer är länkade till ditt konto.',
       oidcUnlinked: 'Konto bortkopplat.',
+      oidcUnlinkConfirmTitle: 'Koppla bort kontot?',
+      oidcUnlinkConfirmMessage: 'Du kan inte längre logga in med {{provider}}.',
+      oidcUnlinkConfirmAction: 'Koppla bort',
     },
 
     // Session Policy (#1706) — admin-configurable session lifetime ceiling.
@@ -4947,8 +4951,6 @@ errors: {
     linkToSpoolman: 'Länka till Spoolman',
     openInSpoolman: 'Öppna i Spoolman',
     unlinkSpool: 'Avlänka rulle',
-    unlinkConfirmTitle: 'Avlänka rulle?',
-    unlinkConfirmMessage: 'Detta kommer att ta bort rullen från detta fack. Rulldatan i sig kommer att förbli oförändrad.',
     selectSpool: 'Välj rulle',
     noUnlinkedSpools: 'Inga olänkade rullar tillgängliga',
     linkSuccess: 'Rulle tilldelad',
@@ -5713,6 +5715,8 @@ errors: {
     backupSkippedNoChanges: 'Säkerhetskopia hoppade över - inga ändringar',
     backupFailed2: 'Kunde inte skapa säkerhetskopia: {{message}}',
     clearedLogs: 'Rensade {{count}} loggar',
+    clearHistoryConfirmTitle: 'Rensa säkerhetskopieringshistorik?',
+    clearHistoryConfirmMessage: 'Detta tar bort alla poster i säkerhetskopieringshistoriken. Själva säkerhetskopiorna behålls.',
     failedToClearLogs: 'Kunde inte rensa loggar: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7218,6 +7222,9 @@ errors: {
     hexLabel: 'Hex: #{{hex}}',
     resetting: 'Återställer...',
     resetSlot: 'Återställ fack',
+    resetConfirmTitle: 'Återställa fack?',
+    resetConfirmMessage: 'Detta rensar filamentinställningen för det här facket på skrivaren.',
+    resetBlockedPrinting: 'Inte tillgängligt medan skrivaren skriver ut',
     cancel: 'Avbryt',
     configuring: 'Konfigurerar...',
     configureSlot: 'Konfigurera fack',
@@ -8303,6 +8310,8 @@ errors: {
   },
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: 'Tömma inköpslistan?',
+    clearAllConfirmMessage: 'Detta tar bort alla {{count}} objekt från inköpslistan.',
     title: 'Prognos',
     noSpools: 'Inga aktiva rullar hittade. Lägg till rullar till din inventering för att se prognosdata.',
     noUsageData: 'Inga användningsdata tillgängliga — kan inte projicera lagertidslinje.',

+ 11 - 2
frontend/src/i18n/locales/tr.ts

@@ -940,6 +940,7 @@ export default {
       done: 'Tamam',
       starting: 'Başlatılıyor...',
       uploadFirmware: 'Firmware Yükle',
+      printerBusy: 'Yazıcı meşgul. Ürün yazılımı yalnızca boştayken yüklenebilir.',
       uploadFailed: 'Yükleme başlatılamadı: {{error}}',
       uploadedToast: 'Firmware yüklendi! Güncellemeyi yazıcı ekranından tetikleyin.',
       availableVersions: 'Kullanılabilir sürümler',
@@ -3047,6 +3048,9 @@ export default {
       linkedAccounts: 'Bağlı SSO Hesapları',
       linkedAccountsDesc: 'Bu harici kimlik sağlayıcıları hesabınıza bağlıdır.',
       oidcUnlinked: 'Hesap bağlantısı kaldırıldı.',
+      oidcUnlinkConfirmTitle: 'Hesap bağlantısı kaldırılsın mı?',
+      oidcUnlinkConfirmMessage: 'Artık {{provider}} ile oturum açamayacaksınız.',
+      oidcUnlinkConfirmAction: 'Bağlantıyı kaldır',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4902,8 +4906,6 @@ export default {
     linkToSpoolman: "Spoolman'a Bağla",
     openInSpoolman: "Spoolman'da Aç",
     unlinkSpool: 'Makara Bağlantısını Kaldır',
-    unlinkConfirmTitle: 'Makara Atamasını Kaldır?',
-    unlinkConfirmMessage: 'Bu, makarayı bu yuvadan kaldıracak. Makara verisinin kendisi değişmeyecek.',
     selectSpool: 'Makara Seç',
     noUnlinkedSpools: 'Atanmamış makara yok',
     linkSuccess: 'Makara başarıyla atandı',
@@ -5646,6 +5648,8 @@ export default {
     backupSkippedNoChanges: 'Yedekleme atlandı - değişiklik yok',
     backupFailed2: 'Yedekleme başarısız: {{message}}',
     clearedLogs: '{{count}} günlük temizlendi',
+    clearHistoryConfirmTitle: 'Yedekleme geçmişi temizlensin mi?',
+    clearHistoryConfirmMessage: 'Bu, yedekleme geçmişindeki tüm kayıtları siler. Yedeklerin kendisi korunur.',
     failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
 
     // Restore from Git backup (#2656)
@@ -7112,6 +7116,9 @@ export default {
     hexLabel: 'Hex: #{{hex}}',
     resetting: 'Sıfırlanıyor...',
     resetSlot: 'Yuvayı Sıfırla',
+    resetConfirmTitle: 'Yuva sıfırlansın mı?',
+    resetConfirmMessage: 'Bu, yazıcıdaki bu yuvanın filament ayarını siler.',
+    resetBlockedPrinting: 'Yazıcı baskı yaparken kullanılamaz',
     cancel: 'İptal',
     configuring: 'Yapılandırılıyor...',
     configureSlot: 'Yuvayı Yapılandır',
@@ -8192,6 +8199,8 @@ export default {
   },
   // Tahmin ve Envanter Zekası
   forecast: {
+    clearAllConfirmTitle: 'Alışveriş listesi temizlensin mi?',
+    clearAllConfirmMessage: 'Bu, alışveriş listesindeki {{count}} öğenin tümünü kaldırır.',
     title: 'Tahmin',
     noSpools: 'Aktif makara bulunamadı. Tahmin verisini görmek için envanterinize makara ekleyin.',
     noUsageData: 'Kullanım verisi mevcut değil — stok zaman çizelgesi öngörülemiyor.',

+ 11 - 2
frontend/src/i18n/locales/uk.ts

@@ -944,6 +944,7 @@ export default {
       done: "Готово",
       starting: "Запуск…",
       uploadFirmware: "Вивантажити прошивку",
+      printerBusy: "Принтер зайнятий. Прошивку можна завантажити лише в режимі простою.",
       uploadFailed: "Не вдалося почати вивантаження: {{error}}",
       uploadedToast: "Прошивку вивантажено! Запустіть оновлення з екрана принтера.",
       availableVersions: "Доступні версії",
@@ -3061,6 +3062,9 @@ export default {
       linkedAccounts: "Пов’язані облікові записи SSO.",
       linkedAccountsDesc: "Ці зовнішні постачальники ідентифікаційної інформації пов’язані з вашим обліковим записом.",
       oidcUnlinked: "Обліковий запис від’єднано.",
+      oidcUnlinkConfirmTitle: "Від'єднати обліковий запис?",
+      oidcUnlinkConfirmMessage: "Ви більше не зможете входити через {{provider}}.",
+      oidcUnlinkConfirmAction: "Від'єднати",
     },
 
     // Session Policy (#1706) — admin-configurable session lifetime ceiling.
@@ -4945,8 +4949,6 @@ export default {
     linkToSpoolman: "Посилання на Spoolman",
     openInSpoolman: "Відкрити в Spoolman",
     unlinkSpool: "Від’єднати котушка",
-    unlinkConfirmTitle: "Скасувати призначення котушки?",
-    unlinkConfirmMessage: "Це призведе до видалення котушки з цього слота. Самі дані котушки залишаться незмінними.",
     selectSpool: "Вибрати Spool",
     noUnlinkedSpools: "Немає непризначених котушок",
     linkSuccess: "Котушку успішно призначено",
@@ -5710,6 +5712,8 @@ export default {
     backupSkippedNoChanges: "Резервне копіювання пропущено – без змін",
     backupFailed2: "Помилка резервного копіювання: {{message}}",
     clearedLogs: "Очищено журнали {{count}}.",
+    clearHistoryConfirmTitle: "Очистити історію резервних копій?",
+    clearHistoryConfirmMessage: "Усі записи історії резервного копіювання буде видалено. Самі резервні копії збережуться.",
     failedToClearLogs: "Не вдалося очистити журнали: {{message}}",
 
     // Restore from Git backup (#2656)
@@ -7210,6 +7214,9 @@ export default {
     hexLabel: "Шістнадцятковий: #{{hex}}",
     resetting: "Скидання...",
     resetSlot: "Скинути слот",
+    resetConfirmTitle: "Скинути слот?",
+    resetConfirmMessage: "Налаштування філаменту цього слота на принтері буде видалено.",
+    resetBlockedPrinting: "Недоступно під час друку",
     cancel: "Скасувати",
     configuring: "Налаштування...",
     configureSlot: "Налаштувати слот",
@@ -8295,6 +8302,8 @@ export default {
   },
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: "Очистити список покупок?",
+    clearAllConfirmMessage: "Усі позиції ({{count}}) буде видалено зі списку покупок.",
     title: "Прогноз",
     noSpools: "Не знайдено активних котушок. Додайте котушки до свого інвентарю, щоб переглянути прогнозні дані.",
     noUsageData: "Немає даних про використання — неможливо спроєктувати хронологію запасів.",

+ 11 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -940,6 +940,7 @@ export default {
       done: '完成',
       starting: '启动中...',
       uploadFirmware: '上传固件',
+      printerBusy: '打印机正忙。只有在空闲时才能上传固件。',
       uploadFailed: '上传启动失败:{{error}}',
       uploadedToast: '固件已上传!请在打印机屏幕上触发更新。',
       availableVersions: '可用版本',
@@ -3030,6 +3031,9 @@ export default {
       linkedAccounts: '已关联的 SSO 账户',
       linkedAccountsDesc: '以下外部身份提供商已与您的账户关联。',
       oidcUnlinked: '账户已解除关联。',
+      oidcUnlinkConfirmTitle: '解除账户关联?',
+      oidcUnlinkConfirmMessage: '你将无法再使用 {{provider}} 登录。',
+      oidcUnlinkConfirmAction: '解除关联',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4900,8 +4904,6 @@ export default {
     linkToSpoolman: '链接到 Spoolman',
     openInSpoolman: '在 Spoolman 中打开',
     unlinkSpool: '取消链接耗材',
-    unlinkConfirmTitle: '取消分配料盘?',
-    unlinkConfirmMessage: '这将从此插槽中移除料盘。料盘本身的数据将保持不变。',
     selectSpool: '选择耗材',
     noUnlinkedSpools: '无未分配的料盘',
     linkSuccess: '料盘分配成功',
@@ -5656,6 +5658,8 @@ export default {
     backupSkippedNoChanges: '备份已跳过 - 无更改',
     backupFailed2: '备份失败:{{message}}',
     clearedLogs: '已清除 {{count}} 条日志',
+    clearHistoryConfirmTitle: '清除备份历史?',
+    clearHistoryConfirmMessage: '这会删除备份历史中的所有条目。备份本身会保留。',
     failedToClearLogs: '清除日志失败:{{message}}',
 
     // Restore from Git backup (#2656)
@@ -7154,6 +7158,9 @@ export default {
     hexLabel: '十六进制:#{{hex}}',
     resetting: '重置中...',
     resetSlot: '重置槽位',
+    resetConfirmTitle: '重置槽位?',
+    resetConfirmMessage: '这会清除打印机上此槽位的耗材设置。',
+    resetBlockedPrinting: '打印机打印时不可用',
     cancel: '取消',
     configuring: '配置中...',
     configureSlot: '配置槽位',
@@ -8238,6 +8245,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: '清空购物清单?',
+    clearAllConfirmMessage: '这会从购物清单中移除全部 {{count}} 项。',
     title: '库存预测',
     noSpools: '未找到活跃的料卷。请将料卷添加到库存中以查看预测数据。',
     noUsageData: '无可用使用数据 — 无法预测库存时间线。',

+ 11 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -940,6 +940,7 @@ export default {
       done: '完成',
       starting: '啟動中...',
       uploadFirmware: '上傳韌體',
+      printerBusy: '印表機忙碌中。只有在閒置時才能上傳韌體。',
       uploadFailed: '上傳啟動失敗:{{error}}',
       uploadedToast: '韌體已上傳!請在印表機螢幕上觸發更新。',
       availableVersions: '可用版本',
@@ -3030,6 +3031,9 @@ export default {
       linkedAccounts: '已連結的 SSO 帳戶',
       linkedAccountsDesc: '以下外部身份提供者已與您的帳戶連結。',
       oidcUnlinked: '帳戶已解除連結。',
+      oidcUnlinkConfirmTitle: '解除帳號連結?',
+      oidcUnlinkConfirmMessage: '你將無法再使用 {{provider}} 登入。',
+      oidcUnlinkConfirmAction: '解除連結',
     },
     // Session Policy (#1706)
     sessionPolicy: {
@@ -4900,8 +4904,6 @@ export default {
     linkToSpoolman: '連結到 Spoolman',
     openInSpoolman: '在 Spoolman 中開啟',
     unlinkSpool: '取消連結耗材',
-    unlinkConfirmTitle: '取消指派料盤?',
-    unlinkConfirmMessage: '這將從此插槽中移除料盤。料盤本身的資料將保持不變。',
     selectSpool: '選擇耗材',
     noUnlinkedSpools: '無未指派的料盤',
     linkSuccess: '料盤指派成功',
@@ -5656,6 +5658,8 @@ export default {
     backupSkippedNoChanges: '備份已跳過 - 無更改',
     backupFailed2: '備份失敗:{{message}}',
     clearedLogs: '已清除 {{count}} 條日誌',
+    clearHistoryConfirmTitle: '清除備份紀錄?',
+    clearHistoryConfirmMessage: '這會刪除備份紀錄中的所有項目。備份本身會保留。',
     failedToClearLogs: '清除日誌失敗:{{message}}',
 
     // Restore from Git backup (#2656)
@@ -7154,6 +7158,9 @@ export default {
     hexLabel: '十六進位:#{{hex}}',
     resetting: '重設中...',
     resetSlot: '重設槽位',
+    resetConfirmTitle: '重設槽位?',
+    resetConfirmMessage: '這會清除印表機上此槽位的耗材設定。',
+    resetBlockedPrinting: '印表機列印時無法使用',
     cancel: '取消',
     configuring: '設定中...',
     configureSlot: '設定槽位',
@@ -8238,6 +8245,8 @@ export default {
 
   // Forecast & Inventory Intelligence
   forecast: {
+    clearAllConfirmTitle: '清空購物清單?',
+    clearAllConfirmMessage: '這會從購物清單中移除全部 {{count}} 項。',
     title: '庫存預測',
     noSpools: '未找到活躍的料卷。請將料卷新增至庫存以查看預測資料。',
     noUsageData: '無可用使用資料 — 無法預測庫存時間線。',

+ 16 - 104
frontend/src/pages/PrintersPage.tsx

@@ -186,7 +186,6 @@ import { AmsBackupModal } from '../components/AmsBackupModal';
 import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
 import type { HeaterSensorKind } from '../api/client';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
-import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { AssignSpoolModal } from '../components/AssignSpoolModal';
 import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
@@ -199,7 +198,7 @@ import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
 import { plateCheckFailureText } from '../utils/plateCheck';
 import { amsHasLoadedTray, getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isAmsHtUnit, isBambuLabSpool, trayForSlot, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
-import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
+import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable, IDLE_PRINTER_STATES } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
@@ -2047,7 +2046,6 @@ function PrinterCard({
   spoolmanEnabled = false,
   linkedSpools,
   spoolmanUrl,
-  spoolmanSyncMode,
   onGetAssignment,
   onUnassignSpool,
   spoolmanSpools,
@@ -2085,10 +2083,8 @@ function PrinterCard({
     tempFair: number;
   };
   spoolmanEnabled?: boolean;
-  hasUnlinkedSpools?: boolean;
   linkedSpools?: Record<string, LinkedSpoolInfo>;
   spoolmanUrl?: string | null;
-  spoolmanSyncMode?: string | null;
   spoolAssignments?: SpoolAssignment[];
   onGetAssignment?: (printerId: number, amsId: number, trayId: number) => SpoolAssignment | undefined;
   onUnassignSpool?: (printerId: number, amsId: number, trayId: number) => void;
@@ -2240,13 +2236,6 @@ function PrinterCard({
     initialKind: HeaterSensorKind;
     availableKinds: HeaterSensorKind[];
   } | null>(null);
-  const [linkSpoolModal, setLinkSpoolModal] = useState<{
-    tagUid: string;
-    trayUuid: string;
-    printerId: number;
-    amsId: number;
-    trayId: number;
-  } | null>(null);
   const [assignSpoolModal, setAssignSpoolModal] = useState<{
     printerId: number;
     amsId: number;
@@ -2710,19 +2699,6 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
   });
 
-  const unlinkSpoolMutation = useMutation({
-    mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
-    onSuccess: (result) => {
-      showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');
-      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
-      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
-    },
-    onError: (error: Error) => {
-      showToast(error.message || t('spoolman.unlinkFailed'), 'error');
-    },
-  });
-
   // AMS drying mutations
   const startDryingMutation = useMutation({
     mutationFn: ({ amsId, temp, duration, filament, rotateTray }: { amsId: number; temp: number; duration: number; filament: string; rotateTray: boolean }) =>
@@ -5701,22 +5677,6 @@ function PrinterCard({
                                           linkedSpoolId: slotAssignmentForFill?.spoolman_spool_id
                                             ?? (trayTag ? linkedSpools?.[trayTag]?.id : undefined),
                                           spoolmanUrl,
-                                          syncMode: spoolmanSyncMode,
-                                          // Suppress Link button when slot is already occupied by ANY assignment
-                                          // (Spoolman SlotAssignment OR local SpoolAssignment). Phase 9 only
-                                          // suppressed for Spoolman; the maintainer screenshot shows the badge
-                                          // still appearing on slots with a local Devil Design PLA assigned.
-                                          onLinkSpool: (spoolmanEnabled && !slotAssignmentForFill && !inventoryAssignment) ? () => {
-                                            const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx)).toUpperCase();
-                                            setLinkSpoolModal({
-                                              tagUid: filamentData.tagUid || linkTag,
-                                              trayUuid: filamentData.trayUuid || '',
-                                              printerId: printer.id,
-                                              amsId: ams.id,
-                                              trayId: slotIdx,
-                                            });
-                                          } : undefined,
-                                          onUnlinkSpool: linkedSpool?.id ? () => unlinkSpoolMutation.mutate(linkedSpool.id) : undefined,
                                         }}
                                         inventory={(() => {
                                           if (spoolmanEnabled) {
@@ -6119,19 +6079,6 @@ function PrinterCard({
                                       linkedSpoolId: htSlotAssignmentForFill?.spoolman_spool_id
                                         ?? (htTrayTag ? linkedSpools?.[htTrayTag]?.id : undefined),
                                       spoolmanUrl,
-                                      syncMode: spoolmanSyncMode,
-                                      // Suppress Link button when slot is occupied by ANY assignment (Phase 13 P13-6d)
-                                      onLinkSpool: (spoolmanEnabled && !htSlotAssignmentForFill && !htInventoryAssignment) ? () => {
-                                        const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId)).toUpperCase();
-                                        setLinkSpoolModal({
-                                          tagUid: filamentData.tagUid || linkTag,
-                                          trayUuid: filamentData.trayUuid || '',
-                                          printerId: printer.id,
-                                          amsId: ams.id,
-                                          trayId: htSlotId,
-                                        });
-                                      } : undefined,
-                                      onUnlinkSpool: htLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(htLinkedSpool.id) : undefined,
                                     }}
                                     inventory={(() => {
                                       if (spoolmanEnabled) {
@@ -6410,19 +6357,6 @@ function PrinterCard({
                                         linkedSpoolId: extSlotAssignmentForFill?.spoolman_spool_id
                                           ?? (extTrayTag ? linkedSpools?.[extTrayTag]?.id : undefined),
                                         spoolmanUrl,
-                                        syncMode: spoolmanSyncMode,
-                                        // Suppress Link button when slot is occupied by ANY assignment (Phase 13 P13-6d)
-                                        onLinkSpool: (spoolmanEnabled && !extSlotAssignmentForFill && !extInventoryAssignment) ? () => {
-                                          const linkTag = (extFilamentData.trayUuid || extFilamentData.tagUid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId)).toUpperCase();
-                                          setLinkSpoolModal({
-                                            tagUid: extFilamentData.tagUid || linkTag,
-                                            trayUuid: extFilamentData.trayUuid || '',
-                                            printerId: printer.id,
-                                            amsId: 255,
-                                            trayId: slotTrayId,
-                                          });
-                                        } : undefined,
-                                        onUnlinkSpool: extLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(extLinkedSpool.id) : undefined,
                                       }}
                                       inventory={(() => {
                                         if (spoolmanEnabled) {
@@ -7311,19 +7245,6 @@ function PrinterCard({
         />
       )}
 
-      {/* Link Spool Modal */}
-      {linkSpoolModal && (
-        <LinkSpoolModal
-          isOpen={!!linkSpoolModal}
-          onClose={() => setLinkSpoolModal(null)}
-          tagUid={linkSpoolModal.tagUid}
-          trayUuid={linkSpoolModal.trayUuid}
-          printerId={linkSpoolModal.printerId}
-          amsId={linkSpoolModal.amsId}
-          trayId={linkSpoolModal.trayId}
-        />
-      )}
-
       {/* Assign Spool Modal */}
       {assignSpoolModal && (
         <AssignSpoolModal
@@ -7346,6 +7267,7 @@ function PrinterCard({
           slotInfo={configureSlotModal}
           printerModel={mapModelCode(printer.model) || undefined}
           nozzleDiameter={resolveSlotNozzleDiameter(status, configureSlotModal.amsId)}
+          printerState={status?.state}
           onSuccess={() => {
             // Refresh slot presets to show updated profile name
             queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
@@ -7383,6 +7305,7 @@ function PrinterCard({
         <FirmwareUpdateModal
           printer={printer}
           firmwareInfo={firmwareInfo}
+          printerState={status?.state ?? null}
           onClose={() => setShowFirmwareModal(false)}
         />
       )}
@@ -8185,10 +8108,12 @@ export function AddPrinterModal({
 function FirmwareUpdateModal({
   printer,
   firmwareInfo,
+  printerState,
   onClose,
 }: {
   printer: Printer;
   firmwareInfo: FirmwareUpdateInfo;
+  printerState: string | null;
   onClose: () => void;
 }) {
   const { t } = useTranslation();
@@ -8196,6 +8121,8 @@ function FirmwareUpdateModal({
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const canUpdate = hasPermission('firmware:update');
+  // Firmware goes onto the SD card the printer prints from: only while idle.
+  const printerIdle = IDLE_PRINTER_STATES.includes(printerState ?? '');
   const [uploadStatus, setUploadStatus] = useState<FirmwareUploadStatus | null>(null);
   const [isUploading, setIsUploading] = useState(false);
   const [pollInterval, setPollInterval] = useState<NodeJS.Timeout | null>(null);
@@ -8205,7 +8132,8 @@ function FirmwareUpdateModal({
 
   // Prepare check query — runs when a version is selected and user can update
   const { data: prepareInfo, isLoading: isPreparing } = useQuery({
-    queryKey: ['firmwarePrepare', printer.id, selectedVersion],
+    // Asked again once the printer turns idle: a busy printer's answer says no.
+    queryKey: ['firmwarePrepare', printer.id, selectedVersion, printerIdle],
     queryFn: () => firmwareApi.prepareUpload(printer.id, selectedVersion ?? undefined),
     staleTime: 30000,
     enabled: !!selectedVersion && canUpdate && !isUploading,
@@ -8373,7 +8301,12 @@ function FirmwareUpdateModal({
             </div>
           ) : prepareInfo && !isUploading && !uploadStatus ? (
             <div className="mb-4">
-              {prepareInfo.can_proceed ? (
+              {prepareInfo.can_proceed && !printerIdle ? (
+                <div className="flex items-center gap-2 text-red-700 dark:text-red-400 text-sm">
+                  <AlertCircle className="w-4 h-4 flex-shrink-0" />
+                  {t('printers.firmwareModal.printerBusy')}
+                </div>
+              ) : prepareInfo.can_proceed ? (
                 <div className="flex items-center gap-2 text-bambu-green text-sm">
                   <Box className="w-4 h-4" />
                   {t('printers.firmwareModal.sdCardReady')}
@@ -8437,7 +8370,7 @@ function FirmwareUpdateModal({
             <Button variant="secondary" onClick={onClose}>
               {uploadStatus?.status === 'complete' ? t('printers.firmwareModal.done') : t('common.cancel')}
             </Button>
-            {prepareInfo?.can_proceed && !isUploading && uploadStatus?.status !== 'complete' && canUpdate && (
+            {prepareInfo?.can_proceed && printerIdle && !isUploading && uploadStatus?.status !== 'complete' && canUpdate && (
               <Button
                 onClick={handleStartUpload}
                 disabled={uploadMutation.isPending}
@@ -9092,23 +9025,6 @@ export function PrintersPage() {
   });
   const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;
 
-  // Fetch Spoolman settings to get sync mode
-  const { data: spoolmanSettings } = useQuery({
-    queryKey: ['spoolman-settings'],
-    queryFn: api.getSpoolmanSettings,
-    enabled: !!spoolmanEnabled,
-    staleTime: 60 * 1000, // 1 minute
-  });
-  const spoolmanSyncMode = spoolmanSettings?.spoolman_sync_mode;
-
-  // Fetch unlinked spools to know if link button should be enabled
-  const { data: unlinkedSpools } = useQuery({
-    queryKey: ['unlinked-spools'],
-    queryFn: api.getUnlinkedSpools,
-    enabled: !!spoolmanEnabled,
-    staleTime: 30 * 1000, // 30 seconds
-  });
-  const hasUnlinkedSpools = unlinkedSpools && unlinkedSpools.length > 0;
 
   // Fetch linked spools map (tag -> spool_id) to know which spools are already in Spoolman
   const { data: linkedSpoolsData } = useQuery({
@@ -10060,10 +9976,8 @@ export function PrintersPage() {
                         tempFair: Number(settings.ams_temp_fair) || 35,
                       } : undefined}
                       spoolmanEnabled={spoolmanEnabled}
-                      hasUnlinkedSpools={hasUnlinkedSpools}
                       linkedSpools={linkedSpools}
                       spoolmanUrl={spoolmanStatus?.url}
-                      spoolmanSyncMode={spoolmanSyncMode}
                       onGetAssignment={getAssignment}
                       onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
                       spoolmanSpools={spoolmanSpools}
@@ -10107,10 +10021,8 @@ export function PrintersPage() {
               viewMode={viewMode}
               cardSize={cardSize}
               spoolmanEnabled={spoolmanEnabled}
-              hasUnlinkedSpools={hasUnlinkedSpools}
               linkedSpools={linkedSpools}
               spoolmanUrl={spoolmanStatus?.url}
-              spoolmanSyncMode={spoolmanSyncMode}
               onGetAssignment={getAssignment}
               onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
               spoolmanSpools={spoolmanSpools}

+ 2 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1006,7 +1006,8 @@ export function SettingsPage() {
     const updateData: UserUpdate = {
       username: userFormData.username || undefined,
       password: userFormData.password || undefined,
-      email: userFormData.email || undefined,
+      // Empty removes the email; the backend reads null as "unchanged".
+      email: userFormData.email,
       role: userFormData.role,
       group_ids: userFormData.group_ids,
     };

+ 1 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -690,6 +690,7 @@ export function SpoolBuddyAmsPage() {
           slotInfo={configureSlotModal}
           printerModel={mapModelCode(printer?.model ?? null) || undefined}
           nozzleDiameter={resolveSlotNozzleDiameter(status, configureSlotModal.amsId)}
+          printerState={status?.state}
           fullScreen
           onSuccess={() => {
             queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });

+ 7 - 1
frontend/src/utils/printer.ts

@@ -58,6 +58,12 @@ export function getWifiStrength(rssi: number): { labelKey: string; color: string
 
 import type { PrinterStatus, PrintQueueItem } from '../api/client';
 
+/** Gcode states with no print loaded. */
+export const IDLE_PRINTER_STATES = ['IDLE', 'FINISH', 'FAILED'];
+
+/** Gcode states in which a print is loaded or starting (mirrors the backend's ACTIVE_PRINT_STATES). */
+export const ACTIVE_PRINT_STATES = ['RUNNING', 'PAUSE', 'PREPARE', 'SLICING'];
+
 /**
  * True when a queue item aimed at this printer would start now rather than wait.
  *
@@ -72,7 +78,7 @@ export function isPrinterCurrentlyDispatchable(status: PrinterStatus | undefined
   if (status.awaiting_plate_clear) return false;
   // A parked drying timer (#2896) never ends, so it does not hold a print back.
   if (status.ams?.some((ams) => ams.dry_time > 0 && !ams.dry_countdown_stalled)) return false;
-  return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
+  return IDLE_PRINTER_STATES.includes(status.state ?? '');
 }
 
 /**