Bladeren bron

Security hardening (maziggy/bambuddy-security #18)

maziggy 1 week geleden
bovenliggende
commit
b1be388a7d
90 gewijzigde bestanden met toevoegingen van 606 en 38 verwijderingen
  1. 3 4
      backend/app/api/routes/archives.py
  2. 18 7
      backend/app/services/github_backup.py
  3. 99 22
      backend/app/services/github_restore.py
  4. 34 0
      backend/app/services/secret_fields.py
  5. 19 0
      backend/tests/unit/test_energy_snapshots.py
  6. 122 0
      backend/tests/unit/test_github_backup_preset_secrets.py
  7. 106 0
      backend/tests/unit/test_github_backup_settings_secrets.py
  8. 172 0
      backend/tests/unit/test_github_restore_secret_destination.py
  9. 2 0
      frontend/src/i18n/locales/de.ts
  10. 2 0
      frontend/src/i18n/locales/en.ts
  11. 2 0
      frontend/src/i18n/locales/es.ts
  12. 2 0
      frontend/src/i18n/locales/fr.ts
  13. 2 0
      frontend/src/i18n/locales/it.ts
  14. 2 0
      frontend/src/i18n/locales/ja.ts
  15. 2 0
      frontend/src/i18n/locales/ko.ts
  16. 2 0
      frontend/src/i18n/locales/nl.ts
  17. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  18. 2 0
      frontend/src/i18n/locales/ru.ts
  19. 2 0
      frontend/src/i18n/locales/sv.ts
  20. 2 0
      frontend/src/i18n/locales/tr.ts
  21. 2 0
      frontend/src/i18n/locales/uk.ts
  22. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  23. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  24. 0 0
      static/assets/AdditionalSection-Bzicpd_O.js
  25. 0 0
      static/assets/AmsUnitCard-ez56BDNq.js
  26. 0 0
      static/assets/ArchivesPage-DMISChKF.js
  27. 0 0
      static/assets/AssignToAmsModal-pxXICEV9.js
  28. 0 0
      static/assets/CamWallPage-C6Si4T1x.js
  29. 0 0
      static/assets/CameraDiagnoseModal-H6G3d96E.js
  30. 0 0
      static/assets/CameraPage-DXj2vQ-A.js
  31. 0 0
      static/assets/CameraWall-DxmgQGu_.js
  32. 0 0
      static/assets/ColumnConfigModal-HQtHnqn5.js
  33. 0 0
      static/assets/ConfigureAmsSlotModal-JUJsYmRb.js
  34. 0 0
      static/assets/ConnectAuthorizePage-CFR4831E.js
  35. 0 0
      static/assets/ContextMenu-DPYwj7HK.js
  36. 0 0
      static/assets/ExternalLinkPage-Cef2nUxf.js
  37. 0 1
      static/assets/FileManagerPage-DDYgLd2J.js
  38. 0 0
      static/assets/FileUploadModal-BJfQgpk4.js
  39. 0 0
      static/assets/FinancePage-B7nDhRJD.js
  40. 0 0
      static/assets/GCodeViewerPage-B8PuLnI7.js
  41. 0 0
      static/assets/GroupEditPage-hSy4judv.js
  42. 0 0
      static/assets/ImagePreviewModal-BMhQe5AS.js
  43. 0 0
      static/assets/InventoryPage-2PWBUA1i.js
  44. 0 0
      static/assets/LibraryTrashPage-BSf2050D.js
  45. 0 0
      static/assets/LoginPage-BvljDV-D.js
  46. 0 0
      static/assets/MaintenancePage-BcYc3RoR.js
  47. 0 0
      static/assets/ModelSourcesPage-8OE4h7OZ.js
  48. 0 0
      static/assets/NotificationsPage-BDlPWHuv.js
  49. 0 1
      static/assets/PdfPreviewModal-Di0IphFJ.js
  50. 0 0
      static/assets/PhotoGalleryModal-bM_bjYiz.js
  51. 0 0
      static/assets/PrinterLocationsPage-BzGXqzUs.js
  52. 0 0
      static/assets/PrintersPage-BZRzHJph.js
  53. 0 0
      static/assets/ProfilesPage-Bcdfnl8b.js
  54. 0 0
      static/assets/ProjectDetailPage-EFXgQfS7.js
  55. 0 0
      static/assets/ProjectsPage-CvSy4bca.js
  56. 0 0
      static/assets/QueuePage-BFv75z51.js
  57. 0 0
      static/assets/RichTextEditor-DtYnCNRN.js
  58. 0 0
      static/assets/SettingsPage-C4Ndk2C2.js
  59. 0 0
      static/assets/SetupPage-Byzne-wD.js
  60. 0 0
      static/assets/SliceModal-BgSvL4VE.js
  61. 0 0
      static/assets/SpoolBuddyAmsPage-kEBS0-HX.js
  62. 0 0
      static/assets/SpoolBuddyCalibrationPage-BZmeXHfc.js
  63. 0 0
      static/assets/SpoolBuddyDashboard-DL9Rc2v4.js
  64. 0 0
      static/assets/SpoolBuddyInventoryPage-9mo2xsal.js
  65. 1 1
      static/assets/SpoolBuddySettingsPage-D8UaOmAf.js
  66. 0 0
      static/assets/SpoolBuddyWriteTagPage-kr6plpFh.js
  67. 0 0
      static/assets/SpreadsheetPreviewModal-Bao_G9_7.js
  68. 0 0
      static/assets/StatsPage-CdTMu7VP.js
  69. 0 0
      static/assets/StreamOverlayPage-s0P8kW-9.js
  70. 0 0
      static/assets/SystemInfoPage-2r1--fz4.js
  71. 0 0
      static/assets/de-DOo4txq0.js
  72. 0 0
      static/assets/es-urFL7z6G.js
  73. 0 0
      static/assets/fr-CwZ4z3ir.js
  74. 0 0
      static/assets/index-D4l8mBj7.js
  75. 1 1
      static/assets/inventorySearch-KEhFjvkL.js
  76. 0 0
      static/assets/it-f72V0Kl8.js
  77. 0 0
      static/assets/ja-BhGR4pQS.js
  78. 0 0
      static/assets/ko-fHbgl3tQ.js
  79. 0 0
      static/assets/locationSensorDefaults-B639biyx.js
  80. 0 0
      static/assets/nl-Bw7ikSvM.js
  81. 0 0
      static/assets/pdf-o1FJH7yi.js
  82. 0 0
      static/assets/pt-BR-Dywt1DRm.js
  83. 0 0
      static/assets/ru-BeLsoYD8.js
  84. 0 0
      static/assets/sv-BV8SnbfK.js
  85. 0 0
      static/assets/tr-nDTKBQLY.js
  86. 0 0
      static/assets/uk-Di1zTCN8.js
  87. 0 0
      static/assets/utils-rJTOVBDm.js
  88. 0 0
      static/assets/zh-CN-rRsYsvW9.js
  89. 0 0
      static/assets/zh-TW-B7i1_wnq.js
  90. 1 1
      static/index.html

+ 3 - 4
backend/app/api/routes/archives.py

@@ -1495,7 +1495,7 @@ async def _sum_live_plug_totals(db: AsyncSession) -> float:
     available so this can't be date-filtered — use `_sum_snapshot_deltas` for
     that case.
     """
-    from backend.app.api.routes.settings import get_setting
+    from backend.app.api.routes.settings import get_homeassistant_settings
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.homeassistant import homeassistant_service
     from backend.app.services.mqtt_relay import mqtt_relay
@@ -1505,9 +1505,8 @@ async def _sum_live_plug_totals(db: AsyncSession) -> float:
     plugs_result = await db.execute(select(SmartPlug))
     plugs = list(plugs_result.scalars().all())
 
-    ha_url = await get_setting(db, "ha_url") or ""
-    ha_token = await get_setting(db, "ha_token") or ""
-    homeassistant_service.configure(ha_url, ha_token)
+    ha_settings = await get_homeassistant_settings(db)
+    homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
 
     total = 0.0
     for plug in plugs:

+ 18 - 7
backend/app/services/github_backup.py

@@ -12,6 +12,7 @@ from sqlalchemy import desc, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import async_session
+from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN
 from backend.app.models.archive import PrintArchive
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.printer import Printer
@@ -21,6 +22,7 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER, is_secret_setting_key, mask_preset
 
 logger = logging.getLogger(__name__)
 
@@ -43,7 +45,8 @@ def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -
 
     ``user_id`` from the listing is deliberately dropped. It identifies the
     account and adds nothing to a rebuild, and backup repositories can be
-    public.
+    public. A printer connection's credentials in ``setting`` are shown as
+    the placeholder.
     """
     return {
         "setting_id": str(setting_id),
@@ -53,7 +56,7 @@ def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -
         "updated_time": entry.get("updated_time"),
         "base_id": detail.get("base_id"),
         "filament_id": detail.get("filament_id"),
-        "setting": detail.get("setting") or {},
+        "setting": mask_preset(detail.get("setting") or {}),
     }
 
 
@@ -62,14 +65,15 @@ def _orca_profile_record(entry: dict) -> dict:
 
     ``content`` is kept whole rather than picked apart: it is the profile, the
     sync API hands it over inline, and Orca owns its shape. Narrowing it here
-    would mean guessing which keys a future restore needs.
+    would mean guessing which keys a future restore needs. A printer
+    connection's credentials are the exception: shown as the placeholder.
     """
     return {
         "id": str(entry.get("id")) if entry.get("id") is not None else None,
         "name": entry.get("name"),
         "updated_time": entry.get("updated_time"),
         "created_time": entry.get("created_time"),
-        "content": entry.get("content"),
+        "content": mask_preset(entry.get("content")),
     }
 
 
@@ -742,9 +746,16 @@ class GitHubBackupService:
         result = await db.execute(select(Settings))
         settings = result.scalars().all()
 
-        # Filter out sensitive settings
-        sensitive_keys = {"bambu_cloud_token", "auth_secret_key", "manyfold_client_secret"}
-        settings_data = {s.key: s.value for s in settings if s.key not in sensitive_keys}
+        # A credential is written as the placeholder when set (blank counts as
+        # unset, as it does for the restore), so the restore can still tell
+        # whether a switch had one. A URL with a login in it is
+        # left out: masked, it would be restored over a working one.
+        settings_data = {}
+        for s in settings:
+            if is_secret_setting_key(s.key):
+                settings_data[s.key] = SECRET_PLACEHOLDER if (s.value or "").strip() else s.value
+            elif not URL_CREDENTIALS_PATTERN.search(s.value or ""):
+                settings_data[s.key] = s.value
 
         files["settings/app_settings.json"] = {
             "version": "1.0",

+ 99 - 22
backend/app/services/github_restore.py

@@ -54,8 +54,10 @@ from backend.app.models.spool import Spool
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import RestoreCategory
+from backend.app.schemas.settings import AppSettings
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.secret_fields import SETTINGS_SECRETS, SMTP_DESTINATIONS, is_secret_setting_key
 
 logger = logging.getLogger(__name__)
 
@@ -68,19 +70,13 @@ ARCHIVES_PATH = "archives/print_history.json"
 # kprofiles/{printer_serial}/{nozzle_diameter}.json
 _KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
 
-# Settings keys the backup collector already refuses to write. Applied again on
-# the read side because a backup taken before that denylist existed can still
-# contain them, and a restore must not resurrect a stale credential.
-_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
-
-# The primary refusal, not a backstop for the set above. The collector filters
-# exactly bambu_cloud_token and auth_secret_key, so every other credential —
-# mqtt_password, ldap_bind_password, ha_token, prometheus_token — is present in
-# a current backup and is skipped only because its key matches a hint here.
-# _COMPANION_CREDENTIALS sits downstream of that: it withholds a toggle when the
-# credential it needs was refused, so shortening this tuple would both write a
-# stale credential and quietly make that rule inert.
-_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
+# Settings keys holding a credential are refused by name (is_secret_setting_key,
+# shared with the backup collector). A current backup carries them only as the
+# placeholder; one taken before that can still hold the values, and a restore
+# must not resurrect a stale credential. _COMPANION_CREDENTIALS sits downstream
+# of that: it withholds a toggle when the credential it needs was refused, so
+# narrowing the predicate would both write a stale credential and quietly make
+# that rule inert.
 
 # Settings the MQTT relay reads only when it is (re)configured, so restoring the
 # rows is not enough on its own. Mirrors the set the settings PUT handler
@@ -191,8 +187,7 @@ def _created_at_matches(row, created_at: datetime | None) -> bool:
 
 
 def _is_blocked_setting_key(key: str) -> bool:
-    lowered = key.lower()
-    return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
+    return is_secret_setting_key(key)
 
 
 def _is_protected_setting_key(key: str) -> bool:
@@ -267,6 +262,17 @@ _COMPANION_CREDENTIAL_ENV = {"ha_token": "HA_TOKEN"}
 # skips this condition and is judged on local state alone.
 _COMPANION_EXPOSURE_TOGGLES = frozenset({"prometheus_enabled"})
 
+# Setting -> the credentials it decides the destination of. smtp_use_tls
+# decides the SMTP security mode when smtp_security is not set.
+_DESTINATION_SECRETS: dict[str, tuple[str, ...]] = {}
+for _secret, _fields in {
+    **SETTINGS_SECRETS,
+    "smtp_password": (*SMTP_DESTINATIONS, "smtp_use_tls"),
+    "manyfold_client_secret": ("manyfold_url",),
+}.items():
+    for _field in _fields:
+        _DESTINATION_SECRETS[_field] = (*_DESTINATION_SECRETS.get(_field, ()), _secret)
+
 
 def _setting_value_is_true(value: object) -> bool:
     """True if a settings *payload* value would be stored as "on".
@@ -283,6 +289,15 @@ def _setting_value_is_true(value: object) -> bool:
     return str(value).strip().lower() == "true"
 
 
+def _as_setting_text(value: object) -> str:
+    """A setting as its row stores it, so a backup value compares with a local one."""
+    if value is None:
+        return ""
+    if isinstance(value, bool) or str(value).lower() in ("true", "false"):
+        return str(value).lower()
+    return str(value)
+
+
 def _is_usable_credential(value: object) -> bool:
     """True if a credential value is present and not blank.
 
@@ -302,20 +317,27 @@ class _SettingsPlan:
     change. The companion bucket is why this needs a session at all: unlike the
     two name-based buckets it depends on local database state.
 
-    The three buckets are disjoint — a key is classified once, in order.
+    The buckets are disjoint — a key is classified once, in order. The
+    destination bucket depends on local state as well.
     """
 
     blocked: tuple[str, ...] = ()
     protected: tuple[str, ...] = ()
     companion: tuple[str, ...] = ()
+    destination: tuple[str, ...] = ()
 
     @property
     def refused(self) -> frozenset[str]:
-        return frozenset(self.blocked) | frozenset(self.protected) | frozenset(self.companion)
+        return (
+            frozenset(self.blocked)
+            | frozenset(self.protected)
+            | frozenset(self.companion)
+            | frozenset(self.destination)
+        )
 
     @property
     def refused_count(self) -> int:
-        return len(self.blocked) + len(self.protected) + len(self.companion)
+        return len(self.blocked) + len(self.protected) + len(self.companion) + len(self.destination)
 
 
 @dataclass(frozen=True)
@@ -506,7 +528,7 @@ class GitHubRestoreService:
             if not _setting_value_is_true(value):
                 continue
             # Expressed as the predicate rather than assumed, so the map cannot
-            # go quietly inert if _SECRET_KEY_HINTS is ever edited: a credential
+            # go quietly inert if the secret hints are ever edited: a credential
             # the restore is willing to write travels with its toggle.
             if not _is_blocked_setting_key(credential):
                 continue
@@ -521,11 +543,20 @@ class GitHubRestoreService:
                 continue
             candidates[key] = credential
 
-        if not candidates:
+        # Settings that decide where a credential is sent, which the payload
+        # sets. Judged against local state below.
+        destinations = {
+            key: secrets
+            for key, value in values.items()
+            if value is not None and (secrets := _DESTINATION_SECRETS.get(key)) and key not in protected
+        }
+
+        if not candidates and not destinations:
             return _SettingsPlan(blocked=tuple(blocked), protected=tuple(protected))
 
         # One SELECT covering both halves of every candidate pair.
-        wanted = set(candidates) | set(candidates.values())
+        wanted = set(candidates) | set(candidates.values()) | set(destinations)
+        wanted |= {secret for secrets in destinations.values() for secret in secrets}
         rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
         local = {row.key: row.value for row in rows.scalars().all()}
 
@@ -543,10 +574,40 @@ class GitHubRestoreService:
                 continue
             companion.append(toggle)
 
+        # A credential kept here does not follow a destination the backup
+        # changes, as the settings editor refuses the same change without the
+        # credential entered again. A token from the environment is the
+        # deployment's own and may follow, as it may there.
+        destination: list[str] = []
+        for key, secrets in destinations.items():
+            kept = [
+                secret
+                for secret in secrets
+                if _is_usable_credential(local.get(secret))
+                and not (
+                    (env_name := _COMPANION_CREDENTIAL_ENV.get(secret))
+                    and _is_usable_credential(os.environ.get(env_name))
+                )
+            ]
+            if not kept:
+                continue
+            if key in local:
+                current = local[key]
+            elif key in AppSettings.model_fields:
+                current = AppSettings.model_fields[key].get_default(call_default_factory=True)
+            else:
+                # No row and no schema default: nothing to compare with, so
+                # counted as a change.
+                destination.append(key)
+                continue
+            if _as_setting_text(values[key]) != _as_setting_text(current):
+                destination.append(key)
+
         return _SettingsPlan(
             blocked=tuple(blocked),
             protected=tuple(protected),
             companion=tuple(companion),
+            destination=tuple(destination),
         )
 
     async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
@@ -658,7 +719,14 @@ class GitHubRestoreService:
             # policy keys stay unmentioned on purpose.
             plan = await self._plan_settings(db, values)
             detail = None
-            if plan.companion and not plan.blocked:
+            if plan.destination:
+                detail = _Detail(
+                    "settingsDestinationWillKeep",
+                    f"{len(plan.destination)} setting(s) will be left as they are - they would send a credential "
+                    "stored here somewhere else; credentials are never restored",
+                    {"destination": len(plan.destination)},
+                )
+            elif plan.companion and not plan.blocked:
                 # An exposure toggle becomes a candidate whether or not the
                 # backup carried its credential, so this commit can refuse a
                 # switch without having a single credential-like key to skip —
@@ -1637,6 +1705,15 @@ class GitHubRestoreService:
                 keys=keys,
                 count=len(plan.companion),
             )
+        if plan.destination:
+            keys = ", ".join(sorted(plan.destination))
+            tally.note(
+                "settingsDestinationKept",
+                f"{keys} left as they are - this instance has a stored credential that would otherwise be sent "
+                "to the address in the backup. Change them in Settings and enter the credential again.",
+                keys=keys,
+                count=len(plan.destination),
+            )
 
     async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
         """Push restored mqtt_* settings into the live relay.

+ 34 - 0
backend/app/services/secret_fields.py

@@ -11,6 +11,8 @@ from __future__ import annotations
 
 from typing import Any
 
+from backend.app.core.logging_filters import redact_url_credentials
+
 SECRET_PLACEHOLDER = "********"
 
 # The config fields that hold a credential, per provider type. For Discord and
@@ -123,8 +125,40 @@ SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
     "ldap_bind_password": ("ldap_server_url", "ldap_security"),
 }
 
+# Settings rows that hold a credential, by name: a Git backup writes them as
+# the placeholder and a restore never writes them. By hint rather than by list,
+# so a credential setting added later is covered by default.
+SECRET_SETTING_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase", "verifier")
+
+
+def is_secret_setting_key(key: str) -> bool:
+    lowered = key.lower()
+    return any(hint in lowered for hint in SECRET_SETTING_KEY_HINTS)
+
+
 REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
 
+# Slicer preset keys holding a printer connection's credentials (for a LAN
+# printer, its access code). Slicer cloud sync carries them.
+PRESET_SECRET_KEYS = ("printhost_apikey", "printhost_password", "printhost_user")
+
+
+def _mask_url_logins(value: Any) -> Any:
+    if isinstance(value, str):
+        return redact_url_credentials(value)
+    if isinstance(value, list):
+        return [_mask_url_logins(item) for item in value]
+    return value
+
+
+def mask_preset(content: Any) -> Any:
+    """A slicer preset with its connection credentials shown as the placeholder
+    and the login in any of its URLs masked."""
+    if not isinstance(content, dict):
+        return content
+    return {key: _mask_url_logins(value) for key, value in mask_fields(content, PRESET_SECRET_KEYS).items()}
+
+
 # Smart plug secrets and the fields that decide where each is sent.
 SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
     "password": ("plug_type", "ip_address"),

+ 19 - 0
backend/tests/unit/test_energy_snapshots.py

@@ -238,3 +238,22 @@ class TestSumLivePlugTotalsRest:
         total = await _sum_live_plug_totals(db_session)
 
         assert total == pytest.approx(0.4)
+
+
+@pytest.mark.asyncio
+async def test_live_totals_use_the_home_assistant_token_from_the_environment(db_session, monkeypatch):
+    """The environment's token wins over a stored one, as everywhere else."""
+    from backend.app.api.routes.archives import _sum_live_plug_totals
+    from backend.app.models.settings import Settings
+    from backend.app.services.homeassistant import homeassistant_service
+
+    monkeypatch.setenv("HA_TOKEN", "env-value")
+    monkeypatch.delenv("HA_URL", raising=False)
+    db_session.add_all([Settings(key="ha_url", value="http://ha.local"), Settings(key="ha_token", value="row-value")])
+    await db_session.commit()
+    configured = []
+    monkeypatch.setattr(homeassistant_service, "configure", lambda url, token: configured.append((url, token)))
+
+    await _sum_live_plug_totals(db_session)
+
+    assert configured == [("http://ha.local", "env-value")]

+ 122 - 0
backend/tests/unit/test_github_backup_preset_secrets.py

@@ -0,0 +1,122 @@
+"""A printer preset's connection secrets never leave in a Git backup.
+
+A printer connected in the slicer keeps its host's key (for a LAN printer, its
+access code) in the preset, and cloud sync carries it. The backup writes those
+as the placeholder; cloud profiles are never restored, so nothing reads them
+back.
+"""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.github_backup import GitHubBackupService
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER
+
+CONNECTION = {
+    "print_host": "192.168.1.50",
+    "printhost_apikey": "value-one",
+    "printhost_password": "value-two",
+    "printhost_user": "value-three",
+    "printer_model": "X1C",
+}
+MASKED = {
+    "print_host": "192.168.1.50",
+    "printhost_apikey": SECRET_PLACEHOLDER,
+    "printhost_password": SECRET_PLACEHOLDER,
+    "printhost_user": SECRET_PLACEHOLDER,
+    "printer_model": "X1C",
+}
+
+
+@pytest.mark.asyncio
+async def test_an_orca_printer_preset_keeps_its_connection_secrets_out(db_session):
+    svc = MagicMock()
+    svc.list_profiles = AsyncMock(return_value=[{"id": 1, "name": "p", "content": {"type": "machine", **CONNECTION}}])
+    svc.close = AsyncMock()
+    files: dict = {}
+    with patch(
+        "backend.app.api.routes.orca_cloud._build_authenticated_service", new_callable=AsyncMock, return_value=svc
+    ):
+        await GitHubBackupService()._collect_orca_profiles(db_session, files, "global", None)
+
+    (path,) = files
+    assert files[path]["profiles"][0]["content"] == {"type": "machine", **MASKED}
+    assert "value-" not in json.dumps(files)
+
+
+@pytest.mark.asyncio
+async def test_a_bambu_printer_preset_keeps_its_connection_secrets_out(db_session):
+    cloud = MagicMock()
+    cloud.is_authenticated = True
+    cloud.get_slicer_settings = AsyncMock(
+        return_value={"printer": {"private": [{"setting_id": "PMUS1", "name": "p", "version": "1"}], "public": []}}
+    )
+    cloud.get_setting_detail = AsyncMock(
+        return_value={"setting_id": "PMUS1", "name": "p", "base_id": "GM001", "setting": dict(CONNECTION)}
+    )
+    cloud.close = AsyncMock()
+    files: dict = {}
+    with patch("backend.app.api.routes.cloud.build_authenticated_cloud", new_callable=AsyncMock, return_value=cloud):
+        await GitHubBackupService()._collect_bambu_profiles(db_session, files, "global", None)
+
+    assert files["cloud_profiles/bambu/global/printer.json"]["profiles"][0]["setting"] == MASKED
+    assert "value-" not in json.dumps(files)
+
+
+@pytest.mark.asyncio
+async def test_an_empty_connection_secret_stays_empty(db_session):
+    svc = MagicMock()
+    svc.list_profiles = AsyncMock(return_value=[{"id": 1, "name": "p", "content": {"printhost_apikey": ""}}])
+    svc.close = AsyncMock()
+    files: dict = {}
+    with patch(
+        "backend.app.api.routes.orca_cloud._build_authenticated_service", new_callable=AsyncMock, return_value=svc
+    ):
+        await GitHubBackupService()._collect_orca_profiles(db_session, files, "global", None)
+
+    (path,) = files
+    assert files[path]["profiles"][0]["content"] == {"printhost_apikey": ""}
+
+
+@pytest.mark.asyncio
+async def test_a_login_in_a_preset_url_is_masked(db_session):
+    content = {
+        "print_host": "http://pi:value-four@octopi.local",
+        "print_host_webui": "https://value-five@octopi.local/ui",
+        "printer_settings_id": "http://octopi.local",
+        "post_process": ["http://u:value-six@octopi.local/hook", "plain"],
+    }
+    cloud = MagicMock()
+    cloud.is_authenticated = True
+    cloud.get_slicer_settings = AsyncMock(
+        return_value={"printer": {"private": [{"setting_id": "PMUS1", "name": "p", "version": "1"}], "public": []}}
+    )
+    cloud.get_setting_detail = AsyncMock(
+        return_value={"setting_id": "PMUS1", "name": "p", "base_id": "GM001", "setting": dict(content)}
+    )
+    cloud.close = AsyncMock()
+    svc = MagicMock()
+    svc.list_profiles = AsyncMock(return_value=[{"id": 1, "name": "p", "content": dict(content)}])
+    svc.close = AsyncMock()
+    files: dict = {}
+    with (
+        patch("backend.app.api.routes.cloud.build_authenticated_cloud", new_callable=AsyncMock, return_value=cloud),
+        patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service", new_callable=AsyncMock, return_value=svc
+        ),
+    ):
+        await GitHubBackupService()._collect_bambu_profiles(db_session, files, "global", None)
+        await GitHubBackupService()._collect_orca_profiles(db_session, files, "global", None)
+
+    masked = {
+        "print_host": "http://pi:[REDACTED]@octopi.local",
+        "print_host_webui": "https://[REDACTED]@octopi.local/ui",
+        "printer_settings_id": "http://octopi.local",
+        "post_process": ["http://u:[REDACTED]@octopi.local/hook", "plain"],
+    }
+    bambu = files["cloud_profiles/bambu/global/printer.json"]["profiles"][0]["setting"]
+    (orca_path,) = [path for path in files if path.startswith("cloud_profiles/orca/")]
+    assert bambu == masked
+    assert files[orca_path]["profiles"][0]["content"] == masked

+ 106 - 0
backend/tests/unit/test_github_backup_settings_secrets.py

@@ -0,0 +1,106 @@
+"""Settings secrets never leave the instance in a Git backup.
+
+A restore refuses every credential-shaped key, so the backup has no use for
+their values. It still records whether each one was set (the placeholder) so
+the restore can tell a switch whose credential stayed behind from one that
+never had one. A URL setting with a login in it is left out: a masked URL
+restored over a working one would break it.
+"""
+
+import json
+
+import pytest
+from sqlalchemy import delete
+
+from backend.app.models.settings import Settings
+from backend.app.services.github_backup import GitHubBackupService
+from backend.app.services.github_restore import GitHubRestoreService, _is_blocked_setting_key
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER
+
+SECRETS = {
+    "mqtt_password": "value-one",
+    "ha_token": "value-two",
+    "prometheus_token": "value-three",
+    "obico_ml_token": "value-four",
+    "ldap_bind_password": "value-five",
+    "smtp_password": "value-six",
+    "virtual_printer_access_code": "value-seven",
+    "bambu_cloud_token": "value-eight",
+    "auth_secret_key": "value-nine",
+    "manyfold_client_secret": "value-ten",
+    "MQTT_SECRET": "value-eleven",
+    "orca_cloud_pending_verifier": "value-twelve",
+}
+
+
+async def _collect(db_session, rows: dict[str, str]) -> dict:
+    db_session.add_all(Settings(key=key, value=value) for key, value in rows.items())
+    await db_session.commit()
+    files: dict = {}
+    await GitHubBackupService()._collect_settings(db_session, files)
+    return files["settings/app_settings.json"]["settings"]
+
+
+@pytest.mark.asyncio
+async def test_a_set_secret_is_written_as_the_placeholder(db_session):
+    collected = await _collect(db_session, {**SECRETS, "currency": "EUR"})
+
+    assert {key: collected.get(key) for key in SECRETS} == dict.fromkeys(SECRETS, SECRET_PLACEHOLDER)
+    assert collected["currency"] == "EUR"
+    dumped = json.dumps(collected)
+    assert not [value for value in SECRETS.values() if value in dumped]
+
+
+@pytest.mark.asyncio
+async def test_an_empty_secret_stays_empty(db_session):
+    """Blank counts as empty, as it does for the restore."""
+    collected = await _collect(db_session, {"prometheus_token": "", "mqtt_password": "  "})
+
+    assert collected == {"prometheus_token": "", "mqtt_password": "  "}
+
+
+@pytest.mark.asyncio
+async def test_a_backed_up_secret_is_refused_on_restore(db_session):
+    plan = await GitHubRestoreService()._plan_settings(db_session, dict(SECRETS))
+
+    assert sorted(plan.blocked) == sorted(SECRETS)
+
+
+@pytest.mark.asyncio
+async def test_the_backup_hides_exactly_what_the_restore_refuses(db_session):
+    rows = {**SECRETS, "currency": "EUR", "mqtt_enabled": "true", "theme_api": "x", "ldap_server_url": "ldap://h"}
+    collected = await _collect(db_session, rows)
+
+    for key in rows:
+        assert (collected[key] == SECRET_PLACEHOLDER) is _is_blocked_setting_key(key), key
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "url",
+    [
+        "http://user:value-url@spoolman.local:7912/",
+        "https://value-url@spoolman.local",
+        "http://:value-url@spoolman.local",
+        "http://user:value-url@spoolman.local?x=1",
+    ],
+)
+async def test_a_url_setting_with_a_login_is_left_out(db_session, url):
+    collected = await _collect(db_session, {"spoolman_url": url, "ha_url": "http://ha.local:8123"})
+
+    assert "spoolman_url" not in collected
+    assert collected["ha_url"] == "http://ha.local:8123"
+    assert "value-url" not in json.dumps(collected)
+
+
+@pytest.mark.asyncio
+async def test_a_switch_whose_credential_stayed_behind_is_still_left_off_on_restore(db_session):
+    """Restored onto an instance without the password, MQTT must not come on."""
+    collected = await _collect(db_session, {"mqtt_enabled": "true", "mqtt_password": "value-one"})
+    await db_session.execute(delete(Settings))
+    await db_session.commit()
+
+    plan = await GitHubRestoreService()._plan_settings(db_session, collected)
+
+    assert plan.companion == ("mqtt_enabled",)
+    assert "mqtt_password" in plan.blocked

+ 172 - 0
backend/tests/unit/test_github_restore_secret_destination.py

@@ -0,0 +1,172 @@
+"""A restore never sends a credential kept here to a place the backup names.
+
+Credentials are never restored, so the local one stays. The settings that say
+where it is sent (broker, port, TLS, URL) are left as they are when the backup
+would change them, as the settings editor refuses the same change without the
+credential being entered again.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.settings import Settings
+from backend.app.schemas.github_backup import RestoreCategory
+from backend.app.services.github_restore import SETTINGS_PATH, GitHubRestoreService, _CategoryTally
+
+
+async def _local(db_session, rows: dict[str, str]) -> None:
+    db_session.add_all(Settings(key=key, value=value) for key, value in rows.items())
+    await db_session.commit()
+
+
+async def _restore(db_session, values: dict, overwrite: bool = True) -> tuple[_CategoryTally, set[str]]:
+    tally, written = _CategoryTally(), set()
+    await GitHubRestoreService()._restore_settings(
+        db_session, {"settings": values}, overwrite=overwrite, tally=tally, keys_written=written
+    )
+    await db_session.commit()
+    return tally, written
+
+
+async def _value(db_session, key: str) -> str | None:
+    row = (await db_session.execute(select(Settings).where(Settings.key == key))).scalar_one_or_none()
+    return row.value if row else None
+
+
+@pytest.mark.asyncio
+async def test_a_changed_broker_is_left_as_it_is_while_the_password_is_kept(db_session):
+    await _local(db_session, {"mqtt_password": "local-value", "mqtt_broker": "here.local"})
+
+    tally, written = await _restore(
+        db_session, {"mqtt_broker": "elsewhere.example", "mqtt_password": "********", "currency": "EUR"}
+    )
+
+    assert await _value(db_session, "mqtt_broker") == "here.local"
+    assert await _value(db_session, "mqtt_password") == "local-value"
+    assert written == {"currency"}
+    kept = [note for note in tally.notes if note["code"] == "settingsDestinationKept"]
+    assert kept and kept[0]["params"]["keys"] == "mqtt_broker"
+
+
+@pytest.mark.asyncio
+async def test_without_a_stored_credential_the_destination_is_restored(db_session):
+    await _local(db_session, {"mqtt_broker": "here.local", "mqtt_password": "  "})
+
+    _, written = await _restore(db_session, {"mqtt_broker": "elsewhere.example"})
+
+    assert await _value(db_session, "mqtt_broker") == "elsewhere.example"
+    assert written == {"mqtt_broker"}
+
+
+@pytest.mark.asyncio
+async def test_an_unchanged_destination_is_restored_as_usual(db_session):
+    await _local(db_session, {"mqtt_password": "local-value", "mqtt_broker": "here.local", "mqtt_use_tls": "false"})
+
+    tally, written = await _restore(
+        db_session, {"mqtt_broker": "here.local", "mqtt_port": "1883", "mqtt_use_tls": False}
+    )
+
+    assert written == {"mqtt_broker", "mqtt_port", "mqtt_use_tls"}
+    assert "settingsDestinationKept" not in [note["code"] for note in tally.notes]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("key", "value"),
+    [("mqtt_port", "8883"), ("mqtt_use_tls", "true"), ("mqtt_use_tls", True)],
+)
+async def test_a_destination_never_saved_here_counts_from_its_default(db_session, key, value):
+    await _local(db_session, {"mqtt_password": "local-value"})
+
+    _, written = await _restore(db_session, {key: value})
+
+    assert await _value(db_session, key) is None
+    assert written == set()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("secret", "destination"),
+    [("ha_token", "ha_url"), ("obico_ml_token", "obico_ml_url")],
+)
+async def test_a_token_does_not_follow_a_changed_url(db_session, monkeypatch, secret, destination):
+    monkeypatch.delenv("HA_TOKEN", raising=False)
+    await _local(db_session, {secret: "local-value", destination: "http://here.local"})
+
+    _, written = await _restore(db_session, {destination: "http://elsewhere.example"})
+
+    assert await _value(db_session, destination) == "http://here.local"
+    assert written == set()
+
+
+@pytest.mark.asyncio
+async def test_a_deployment_token_may_follow_a_changed_url(db_session, monkeypatch):
+    """Set in the environment, the token is the deployment's own: as in the editor."""
+    monkeypatch.setenv("HA_TOKEN", "env-value")
+    await _local(db_session, {"ha_token": "local-value", "ha_url": "http://here.local"})
+
+    await _restore(db_session, {"ha_url": "http://elsewhere.example"})
+
+    assert await _value(db_session, "ha_url") == "http://elsewhere.example"
+
+
+@pytest.mark.asyncio
+async def test_the_preview_counts_and_names_what_is_left_as_it_is(db_session):
+    await _local(db_session, {"mqtt_password": "local-value", "mqtt_broker": "here.local"})
+    parsed = {SETTINGS_PATH: {"settings": {"mqtt_broker": "elsewhere.example", "currency": "EUR"}}}
+
+    count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+    assert count == 1
+    assert detail is not None and detail.code == "settingsDestinationWillKeep"
+    assert detail.params == {"destination": 1}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("key", "local", "restored"),
+    [
+        ("smtp_host", "mail.here.local", "mail.elsewhere.example"),
+        ("smtp_port", "587", "25"),
+        ("smtp_security", "starttls", "none"),
+        ("smtp_use_tls", "true", "false"),
+    ],
+)
+async def test_the_smtp_password_does_not_follow_a_changed_server(db_session, key, local, restored):
+    await _local(db_session, {"smtp_password": "local-value", key: local})
+
+    _, written = await _restore(db_session, {key: restored})
+
+    assert await _value(db_session, key) == local
+    assert written == set()
+
+
+@pytest.mark.asyncio
+async def test_a_destination_without_a_row_here_is_left_alone_while_a_credential_is_kept(db_session):
+    """Without a row there is no value to compare with: counted as a change."""
+    await _local(db_session, {"smtp_password": "local-value", "smtp_host": "mail.here.local"})
+
+    _, written = await _restore(db_session, {"smtp_security": "none"})
+
+    assert await _value(db_session, "smtp_security") is None
+    assert written == set()
+
+
+@pytest.mark.asyncio
+async def test_the_manyfold_secret_does_not_follow_a_changed_url(db_session):
+    await _local(db_session, {"manyfold_client_secret": "local-value", "manyfold_url": "http://here.local"})
+
+    _, written = await _restore(db_session, {"manyfold_url": "http://elsewhere.example", "manyfold_client_id": "id"})
+
+    assert await _value(db_session, "manyfold_url") == "http://here.local"
+    assert written == {"manyfold_client_id"}
+
+
+@pytest.mark.asyncio
+async def test_without_a_stored_smtp_password_the_server_is_restored(db_session):
+    await _local(db_session, {"smtp_host": "mail.here.local"})
+
+    _, written = await _restore(db_session, {"smtp_host": "mail.elsewhere.example", "smtp_security": "ssl"})
+
+    assert await _value(db_session, "smtp_host") == "mail.elsewhere.example"
+    assert written == {"smtp_host", "smtp_security"}

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

@@ -5675,6 +5675,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
         settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
         settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
+        settingsDestinationWillKeep: '{{destination}} Einstellung(en) bleiben unverändert - sie würden hier gespeicherte Zugangsdaten an ein anderes Ziel senden; Zugangsdaten werden nie wiederhergestellt',
         spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
         archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
         kprofilesPrinterCount: 'über {{count}} Drucker',
@@ -5694,6 +5695,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} zugangsdatenähnliche Schlüssel übersprungen - Geheimnisse bitte manuell erneut eingeben',
         settingsAuthSkipped: '{{count}} Authentifizierungseinstellungen übersprungen - ändern Sie diese unter Einstellungen > Authentifizierung, damit die Aussperrprüfungen greifen',
         settingsCompanionSkipped: '{{keys}} bleiben ausgeschaltet - die jeweils benötigten Zugangsdaten lassen sich nicht aus einem Backup wiederherstellen und sind auf dieser Instanz nicht hinterlegt, ein Einschalten würde die Integration also ohne Authentifizierung lassen',
+        settingsDestinationKept: '{{keys}} unverändert gelassen - diese Instanz hat gespeicherte Zugangsdaten, die sonst an die Adresse aus dem Backup gesendet würden. Ändere sie in den Einstellungen und gib die Zugangsdaten erneut ein.',
         settingsMqttRelayFailed: 'MQTT-Einstellungen wiederhergestellt, aber das Relay konnte nicht neu verbunden werden - Bambuddy neu starten',
         kprofilesAlwaysOverwrite: 'K-Profile überschreiben immer den passenden Slot auf dem Drucker',
         kprofilesAckUnreliable: 'Ein Drucker, der nicht antwortet, zählt weiterhin als wiederhergestellt - überprüfen Sie die Profile am Drucker',

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

@@ -5724,6 +5724,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
         settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
         settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
+        settingsDestinationWillKeep: '{{destination}} setting(s) will be left as they are - they would send a credential stored here somewhere else; credentials are never restored',
         spoolsUsageCount: 'including {{count}} usage record(s)',
         archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
         kprofilesPrinterCount: 'across {{count}} printer(s)',
@@ -5746,6 +5747,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} credential-like key(s) skipped - re-enter secrets manually',
         settingsAuthSkipped: '{{count}} authentication setting(s) skipped - change those in Settings > Authentication so the lockout checks still run',
         settingsCompanionSkipped: '{{keys}} left switched off - the credential each one needs cannot be restored from a backup and this instance has none stored, so switching them on would leave the integration unauthenticated',
+        settingsDestinationKept: '{{keys}} left as they are - this instance has a stored credential that would otherwise be sent to the address in the backup. Change them in Settings and enter the credential again.',
         settingsMqttRelayFailed: 'MQTT settings restored, but the relay could not be reconnected - restart Bambuddy',
         kprofilesAlwaysOverwrite: 'K-profiles always overwrite the matching slot on the printer',
         kprofilesAckUnreliable: 'A printer that does not answer still counts as restored - verify the profiles on the printer',

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

@@ -5682,6 +5682,7 @@ export default {
         settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
         settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
         settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
+        settingsDestinationWillKeep: '{{destination}} ajuste(s) se dejarán como están - enviarían una credencial guardada aquí a otro destino; las credenciales nunca se restauran',
         spoolsUsageCount: 'incluidos {{count}} registros de consumo',
         archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
         kprofilesPrinterCount: 'en {{count}} impresoras',
@@ -5701,6 +5702,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} claves con aspecto de credencial omitidas - vuelve a introducir los secretos manualmente',
         settingsAuthSkipped: '{{count}} ajustes de autenticación omitidos - cámbialos en Ajustes > Autenticación para que sigan aplicándose las comprobaciones de bloqueo',
         settingsCompanionSkipped: '{{keys}} se han dejado desactivados - la credencial que cada uno necesita no puede restaurarse desde una copia y esta instancia no tiene ninguna guardada, así que activarlos dejaría la integración sin autenticación',
+        settingsDestinationKept: '{{keys}} se han dejado como están - esta instancia tiene una credencial guardada que, de lo contrario, se enviaría a la dirección de la copia de seguridad. Cámbialos en Ajustes e introduce la credencial de nuevo.',
         settingsMqttRelayFailed: 'Ajustes MQTT restaurados, pero no se pudo reconectar el relé - reinicia Bambuddy',
         kprofilesAlwaysOverwrite: 'Los perfiles K siempre sobrescriben la ranura correspondiente en la impresora',
         kprofilesAckUnreliable: 'Una impresora que no responde sigue contando como restaurada - verifica los perfiles en la impresora',

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

@@ -5662,6 +5662,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
         settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
         settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
+        settingsDestinationWillKeep: '{{destination}} paramètre(s) resteront inchangés - ils enverraient un identifiant enregistré ici vers une autre destination ; les identifiants ne sont jamais restaurés',
         spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
         archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
         kprofilesPrinterCount: 'sur {{count}} imprimantes',
@@ -5681,6 +5682,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} clés ressemblant à des identifiants ignorées - ressaisissez les secrets manuellement',
         settingsAuthSkipped: '{{count}} réglages d\'authentification ignorés - modifiez-les dans Réglages > Authentification pour que les contrôles de verrouillage s\'appliquent',
         settingsCompanionSkipped: '{{keys}} laissés désactivés - l\'identifiant dont chacun a besoin ne peut pas être restauré depuis une sauvegarde et cette instance n\'en a aucun enregistré ; les activer laisserait donc l\'intégration sans authentification',
+        settingsDestinationKept: '{{keys}} laissés inchangés - cette instance possède un identifiant enregistré qui serait sinon envoyé à l\'adresse de la sauvegarde. Modifiez-les dans les Paramètres et saisissez à nouveau l\'identifiant.',
         settingsMqttRelayFailed: 'Réglages MQTT restaurés, mais le relais n\'a pas pu être reconnecté - redémarrez Bambuddy',
         kprofilesAlwaysOverwrite: 'Les profils K écrasent toujours l\'emplacement correspondant sur l\'imprimante',
         kprofilesAckUnreliable: 'Une imprimante qui ne répond pas compte quand même comme restaurée - vérifiez les profils sur l\'imprimante',

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

@@ -5661,6 +5661,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
         settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
         settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
+        settingsDestinationWillKeep: "{{destination}} impostazione/i resteranno invariate - invierebbero una credenziale salvata qui a un'altra destinazione; le credenziali non vengono mai ripristinate",
         spoolsUsageCount: 'inclusi {{count}} record di consumo',
         archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
         kprofilesPrinterCount: 'su {{count}} stampanti',
@@ -5680,6 +5681,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} chiavi simili a credenziali saltate - reinserisci i segreti manualmente',
         settingsAuthSkipped: '{{count}} impostazioni di autenticazione saltate - modificale in Impostazioni > Autenticazione così i controlli di blocco restano attivi',
         settingsCompanionSkipped: '{{keys}} lasciati disattivati - la credenziale richiesta da ciascuno non può essere ripristinata da un backup e questa istanza non ne ha nessuna salvata, quindi attivarli lascerebbe l\'integrazione senza autenticazione',
+        settingsDestinationKept: "{{keys}} lasciati invariati - questa istanza ha una credenziale salvata che altrimenti verrebbe inviata all'indirizzo del backup. Modificali nelle Impostazioni e inserisci di nuovo la credenziale.",
         settingsMqttRelayFailed: 'Impostazioni MQTT ripristinate, ma il relay non è stato riconnesso - riavvia Bambuddy',
         kprofilesAlwaysOverwrite: 'I profili K sovrascrivono sempre lo slot corrispondente sulla stampante',
         kprofilesAckUnreliable: 'Una stampante che non risponde conta comunque come ripristinata - verifica i profili sulla stampante',

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

@@ -5675,6 +5675,7 @@ export default {
         settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます',
         settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
         settingsCompanionOnlyWillSkip: 'スイッチ {{companion}} 件はオフのままになります - それぞれに必要な認証情報はバックアップから復元できません',
+        settingsDestinationWillKeep: '{{destination}} 件の設定はそのままになります - ここに保存された認証情報を別の宛先に送ってしまうためです。認証情報は復元されません',
         spoolsUsageCount: '使用履歴 {{count}} 件を含む',
         archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
         kprofilesPrinterCount: 'プリンター {{count}} 台分',
@@ -5694,6 +5695,7 @@ export default {
         settingsCredentialsSkipped: '認証情報のようなキー {{count}} 件をスキップしました - シークレットは手動で再入力してください',
         settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください',
         settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります',
+        settingsDestinationKept: '{{keys}} はそのままにしました - このインスタンスには保存済みの認証情報があり、変更するとバックアップのアドレスに送られてしまいます。設定で変更し、認証情報をもう一度入力してください。',
         settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください',
         kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします',
         kprofilesAckUnreliable: '応答しないプリンターも復元済みとして数えます - プロファイルはプリンター側で確認してください',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -5437,6 +5437,7 @@ export default {
         settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다',
         settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다',
         settingsCompanionOnlyWillSkip: '스위치 {{companion}}개는 꺼진 상태로 둡니다 - 각각에 필요한 자격 증명은 백업에서 복원할 수 없습니다',
+        settingsDestinationWillKeep: '설정 {{destination}}개는 그대로 둡니다 - 여기에 저장된 자격 증명을 다른 곳으로 보내게 되기 때문입니다. 자격 증명은 복원되지 않습니다',
         spoolsUsageCount: '사용 기록 {{count}}건 포함',
         archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
         kprofilesPrinterCount: '프린터 {{count}}대 분량',
@@ -5456,6 +5457,7 @@ export default {
         settingsCredentialsSkipped: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰었습니다 - 비밀 값은 직접 다시 입력하세요',
         settingsAuthSkipped: '인증 설정 {{count}}개를 건너뛰었습니다 - 잠금 검사가 계속 동작하도록 설정 > 인증에서 변경하세요',
         settingsCompanionSkipped: '{{keys}}을(를) 꺼진 상태로 두었습니다 - 각 항목에 필요한 자격 증명은 백업에서 복원할 수 없고 이 인스턴스에도 저장되어 있지 않아, 켜면 연동이 인증 없이 열립니다',
+        settingsDestinationKept: '{{keys}}을(를) 그대로 두었습니다 - 이 인스턴스에 저장된 자격 증명이 백업의 주소로 전송될 수 있기 때문입니다. 설정에서 변경하고 자격 증명을 다시 입력하세요.',
         settingsMqttRelayFailed: 'MQTT 설정을 복원했지만 릴레이를 다시 연결하지 못했습니다 - Bambuddy를 재시작하세요',
         kprofilesAlwaysOverwrite: 'K 프로파일은 항상 프린터의 해당 슬롯을 덮어씁니다',
         kprofilesAckUnreliable: '응답하지 않는 프린터도 복원됨으로 집계됩니다 - 프린터에서 프로파일을 확인하세요',

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

@@ -5722,6 +5722,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} sleutel(s) die op inloggegevens lijken worden overgeslagen',
         settingsCompanionWillSkip: '{{count}} sleutel(s) die op inloggegevens lijken worden overgeslagen, en {{companion}} schakelaar(s) die daarvan afhankelijk zijn blijven uitgeschakeld',
         settingsCompanionOnlyWillSkip: '{{companion}} schakelaar(s) blijven uitgeschakeld - de benodigde inloggegevens kunnen niet uit een back-up worden hersteld',
+        settingsDestinationWillKeep: '{{destination}} instelling(en) blijven ongewijzigd - ze zouden hier opgeslagen inloggegevens naar een andere bestemming sturen; inloggegevens worden nooit hersteld',
         spoolsUsageCount: 'inclusief {{count}} verbruiksrecord(s)',
         archivesMetadataOnly: 'Alleen metadata - 3MF-bestanden en miniaturen zitten niet in een Git-back-up',
         kprofilesPrinterCount: 'over {{count}} printer(s)',
@@ -5744,6 +5745,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} sleutel(s) die op inloggegevens lijken overgeslagen - voer geheimen handmatig opnieuw in',
         settingsAuthSkipped: '{{count}} authenticatie-instelling(en) overgeslagen - wijzig deze onder Instellingen > Authenticatie zodat vergrendelingscontroles blijven werken',
         settingsCompanionSkipped: '{{keys}} blijven uitgeschakeld - de benodigde inloggegevens kunnen niet uit een back-up worden hersteld en deze instantie heeft er geen opgeslagen, dus inschakelen zou de integratie zonder authenticatie achterlaten',
+        settingsDestinationKept: '{{keys}} ongewijzigd gelaten - deze instantie heeft opgeslagen inloggegevens die anders naar het adres uit de back-up zouden worden gestuurd. Wijzig ze in Instellingen en voer de inloggegevens opnieuw in.',
         settingsMqttRelayFailed: 'MQTT-instellingen hersteld, maar de relay kon niet opnieuw verbinden - start Bambuddy opnieuw',
         kprofilesAlwaysOverwrite: 'K-profielen overschrijven altijd de overeenkomende sleuf op de printer',
         kprofilesAckUnreliable: 'Een printer die niet antwoordt telt nog steeds als hersteld - controleer de profielen op de printer',

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

@@ -5661,6 +5661,7 @@ export default {
         settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
         settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
         settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
+        settingsDestinationWillKeep: '{{destination}} configuração(ões) ficarão como estão - enviariam uma credencial salva aqui para outro destino; credenciais nunca são restauradas',
         spoolsUsageCount: 'incluindo {{count}} registros de consumo',
         archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
         kprofilesPrinterCount: 'em {{count}} impressoras',
@@ -5680,6 +5681,7 @@ export default {
         settingsCredentialsSkipped: '{{count}} chaves parecidas com credenciais ignoradas - digite os segredos novamente à mão',
         settingsAuthSkipped: '{{count}} configurações de autenticação ignoradas - altere-as em Configurações > Autenticação para que as verificações de bloqueio continuem valendo',
         settingsCompanionSkipped: '{{keys}} ficaram desligados - a credencial que cada um precisa não pode ser restaurada de um backup e esta instância não tem nenhuma armazenada, então ligá-los deixaria a integração sem autenticação',
+        settingsDestinationKept: '{{keys}} ficaram como estão - esta instância tem uma credencial salva que, caso contrário, seria enviada ao endereço do backup. Altere-os em Configurações e informe a credencial novamente.',
         settingsMqttRelayFailed: 'Configurações MQTT restauradas, mas o relay não pôde ser reconectado - reinicie o Bambuddy',
         kprofilesAlwaysOverwrite: 'Os perfis K sempre sobrescrevem o slot correspondente na impressora',
         kprofilesAckUnreliable: 'Uma impressora que não responde ainda conta como restaurada - verifique os perfis na impressora',

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

@@ -5429,6 +5429,7 @@ export default {
         settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
         settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
         settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
+        settingsDestinationWillKeep: 'Настройки ({{destination}}) останутся без изменений - они отправили бы сохранённые здесь учётные данные в другое место; учётные данные никогда не восстанавливаются',
         spoolsUsageCount: 'включая записей расхода: {{count}}',
         archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
         kprofilesPrinterCount: 'по {{count}} принтерам',
@@ -5448,6 +5449,7 @@ export default {
         settingsCredentialsSkipped: 'Ключей, похожих на учётные данные, пропущено: {{count}} - введите секреты вручную',
         settingsAuthSkipped: 'Настроек аутентификации пропущено: {{count}} - меняйте их в разделе «Настройки > Аутентификация», чтобы продолжали работать проверки блокировки',
         settingsCompanionSkipped: '{{keys}} оставлены выключенными - нужные им учётные данные нельзя восстановить из резервной копии, и в этом экземпляре они не сохранены, поэтому включение оставило бы интеграцию без аутентификации',
+        settingsDestinationKept: '{{keys}} оставлены без изменений - у этого экземпляра есть сохранённые учётные данные, которые иначе были бы отправлены на адрес из резервной копии. Измените их в настройках и введите учётные данные заново.',
         settingsMqttRelayFailed: 'Настройки MQTT восстановлены, но переподключить реле не удалось - перезапустите Bambuddy',
         kprofilesAlwaysOverwrite: 'K-профили всегда перезаписывают соответствующий слот на принтере',
         kprofilesAckUnreliable: 'Принтер, который не отвечает, всё равно считается восстановленным - проверьте профили на принтере',

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

@@ -5720,6 +5720,7 @@ errors: {
         settingsCredentialsWillSkip: '{{count}} uppgift(er) av typen lösenord kommer att hoppas över',
         settingsCompanionWillSkip: '{{count}} uppgift(er) av typen lösenord kommer att hoppas över, och {{companion}} växlar som beror på dem kommer att lämnas av',
         settingsCompanionOnlyWillSkip: '{{companion}} växlar kommer att lämnas av — den uppgift som var och en behöver kan inte återställas från en säkerhetskopia',
+        settingsDestinationWillKeep: '{{destination}} inställning(ar) lämnas oförändrade - de skulle skicka en uppgift som sparats här någon annanstans; inloggningsuppgifter återställs aldrig',
         spoolsUsageCount: 'inklusive {{count}} användningspost(er)',
         archivesMetadataOnly: 'Endast metadata — 3MF-filer och minibilder finns inte i en Git-säkerhetskopia',
         kprofilesPrinterCount: 'över {{count}} skrivare',
@@ -5742,6 +5743,7 @@ errors: {
         settingsCredentialsSkipped: '{{count}} uppgift(er) av typen lösenord hoppade över — ange hemligheter manuellt igen',
         settingsAuthSkipped: '{{count}} autentiseringsinställning(ar) hoppade över — ändra dem i Inställningar > Autentisering så att uteslutningskontrollerna fortfarande körs',
         settingsCompanionSkipped: '{{keys}} lämnade avstängda — den uppgift som var och en behöver kan inte återställas från en säkerhetskopia och denna instans har ingen lagrad, så att slå på dem skulle lämna integrationen oautentiserad',
+        settingsDestinationKept: '{{keys}} lämnade oförändrade - den här instansen har en sparad inloggningsuppgift som annars skulle skickas till adressen i säkerhetskopian. Ändra dem i Inställningar och ange uppgiften igen.',
         settingsMqttRelayFailed: 'MQTT-inställningar återställda, men reläet kunde inte återanslutas — starta om Bambuddy',
         kprofilesAlwaysOverwrite: 'K-profiler skriver alltid över det matchande facket på skrivaren',
         kprofilesAckUnreliable: 'En skrivare som inte svarar räknas ändå som återställd — verifiera profilerna på skrivaren',

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

@@ -5650,6 +5650,7 @@ export default {
         settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
         settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
         settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
+        settingsDestinationWillKeep: '{{destination}} ayar olduğu gibi bırakılacak - burada kayıtlı bir kimlik bilgisini başka bir yere gönderirlerdi; kimlik bilgileri asla geri yüklenmez',
         spoolsUsageCount: '{{count}} kullanım kaydı dahil',
         archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
         kprofilesPrinterCount: '{{count}} yazıcı genelinde',
@@ -5669,6 +5670,7 @@ export default {
         settingsCredentialsSkipped: 'Kimlik bilgisi benzeri {{count}} anahtar atlandı - gizli değerleri elle yeniden girin',
         settingsAuthSkipped: '{{count}} kimlik doğrulama ayarı atlandı - kilitlenme kontrolleri çalışmaya devam etsin diye bunları Ayarlar > Kimlik Doğrulama bölümünden değiştirin',
         settingsCompanionSkipped: '{{keys}} kapalı bırakıldı - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez ve bu örnekte kayıtlı değil, dolayısıyla açmak entegrasyonu kimlik doğrulamasız bırakırdı',
+        settingsDestinationKept: "{{keys}} olduğu gibi bırakıldı - bu örnekte kayıtlı bir kimlik bilgisi var ve aksi halde yedekteki adrese gönderilirdi. Bunları Ayarlar'dan değiştirin ve kimlik bilgisini yeniden girin.",
         settingsMqttRelayFailed: 'MQTT ayarları geri yüklendi ancak röle yeniden bağlanamadı - Bambuddy\'yi yeniden başlatın',
         kprofilesAlwaysOverwrite: 'K profilleri yazıcıdaki eşleşen yuvanın her zaman üzerine yazar',
         kprofilesAckUnreliable: 'Yanıt vermeyen bir yazıcı yine de geri yüklendi sayılır - profilleri yazıcıda doğrulayın',

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

@@ -5715,6 +5715,7 @@ export default {
         settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
         settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
         settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
+        settingsDestinationWillKeep: 'Налаштування ({{destination}}) залишаться без змін - вони надіслали б збережені тут облікові дані в інше місце; облікові дані ніколи не відновлюються',
         spoolsUsageCount: "включно із записами використання: {{count}}",
         archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
         kprofilesPrinterCount: "по {{count}} принтерах",
@@ -5734,6 +5735,7 @@ export default {
         settingsCredentialsSkipped: "Ключів, схожих на облікові дані, пропущено: {{count}} - введіть секрети вручну",
         settingsAuthSkipped: "Налаштувань автентифікації пропущено: {{count}} - змінюйте їх у розділі «Налаштування > Автентифікація», щоб перевірки блокування й далі працювали",
         settingsCompanionSkipped: "{{keys}} залишено вимкненими - потрібні їм облікові дані не можна відновити з резервної копії, і в цьому екземплярі вони не збережені, тож увімкнення залишило б інтеграцію без автентифікації",
+        settingsDestinationKept: '{{keys}} залишено без змін - цей екземпляр має збережені облікові дані, які інакше були б надіслані на адресу з резервної копії. Змініть їх у налаштуваннях і введіть облікові дані знову.',
         settingsMqttRelayFailed: "Налаштування MQTT відновлено, але реле не вдалося перепідключити - перезапустіть Bambuddy",
         kprofilesAlwaysOverwrite: "K-профілі завжди перезаписують відповідний слот на принтері",
         kprofilesAckUnreliable: "Принтер, який не відповідає, усе одно вважається відновленим - перевірте профілі на принтері",

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

@@ -5661,6 +5661,7 @@ export default {
         settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
         settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
         settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
+        settingsDestinationWillKeep: '{{destination}} 项设置将保持不变 - 它们会把此处存储的凭据发送到别处;凭据永远不会被恢复',
         spoolsUsageCount: '其中含 {{count}} 条使用记录',
         archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
         kprofilesPrinterCount: '涉及 {{count}} 台打印机',
@@ -5680,6 +5681,7 @@ export default {
         settingsCredentialsSkipped: '已跳过 {{count}} 个疑似凭据的键 - 请手动重新输入密钥',
         settingsAuthSkipped: '已跳过 {{count}} 项认证设置 - 请在“设置 > 认证”中修改,以便锁定检查继续生效',
         settingsCompanionSkipped: '{{keys}} 保持关闭 - 它们各自所需的凭据无法从备份恢复,本实例也没有存储,开启会让集成处于未认证状态',
+        settingsDestinationKept: '{{keys}} 保持不变 - 本实例存储了凭据,否则会被发送到备份中的地址。请在设置中修改它们并重新输入凭据。',
         settingsMqttRelayFailed: 'MQTT 设置已恢复,但中继无法重新连接 - 请重启 Bambuddy',
         kprofilesAlwaysOverwrite: 'K 值配置总是覆盖打印机上对应的槽位',
         kprofilesAckUnreliable: '打印机不回应时仍计为已恢复 - 请在打印机上核对配置',

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

@@ -5661,6 +5661,7 @@ export default {
         settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
         settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
         settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
+        settingsDestinationWillKeep: '{{destination}} 項設定會維持不變 - 它們會把此處儲存的憑證傳送到別處;憑證永遠不會被還原',
         spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
         archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
         kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
@@ -5680,6 +5681,7 @@ export default {
         settingsCredentialsSkipped: '已略過 {{count}} 個疑似憑證的鍵 - 請手動重新輸入密鑰',
         settingsAuthSkipped: '已略過 {{count}} 項驗證設定 - 請在「設定 > 驗證」中修改,讓鎖定檢查繼續生效',
         settingsCompanionSkipped: '{{keys}} 維持關閉 - 它們各自所需的憑證無法從備份還原,本執行個體也沒有儲存,開啟會讓整合處於未驗證狀態',
+        settingsDestinationKept: '{{keys}} 維持不變 - 本執行個體儲存了憑證,否則會被傳送到備份中的位址。請在設定中修改它們並重新輸入憑證。',
         settingsMqttRelayFailed: 'MQTT 設定已還原,但中繼無法重新連線 - 請重新啟動 Bambuddy',
         kprofilesAlwaysOverwrite: 'K 值設定檔一律覆寫印表機上對應的插槽',
         kprofilesAckUnreliable: '印表機未回應時仍計為已還原 - 請在印表機上核對設定檔',

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-Bzicpd_O.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-ez56BDNq.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-DMISChKF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-pxXICEV9.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-C6Si4T1x.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-H6G3d96E.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-DXj2vQ-A.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-DxmgQGu_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-HQtHnqn5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-JUJsYmRb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-CFR4831E.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-DPYwj7HK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-Cef2nUxf.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-DDYgLd2J.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-BJfQgpk4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-B7nDhRJD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-B8PuLnI7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-hSy4judv.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BMhQe5AS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-2PWBUA1i.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-BSf2050D.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-BvljDV-D.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-BcYc3RoR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-8OE4h7OZ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-BDlPWHuv.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-Di0IphFJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-bM_bjYiz.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-BzGXqzUs.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-BZRzHJph.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-Bcdfnl8b.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-EFXgQfS7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-CvSy4bca.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-BFv75z51.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-DtYnCNRN.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-C4Ndk2C2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-Byzne-wD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-BgSvL4VE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-kEBS0-HX.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BZmeXHfc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-DL9Rc2v4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-9mo2xsal.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-D8UaOmAf.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-kr6plpFh.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-Bao_G9_7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-CdTMu7VP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-s0P8kW-9.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-2r1--fz4.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/de-DOo4txq0.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/es-urFL7z6G.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/fr-CwZ4z3ir.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D4l8mBj7.js


+ 1 - 1
static/assets/inventorySearch-CuLyRN65.js → static/assets/inventorySearch-KEhFjvkL.js

@@ -1 +1 @@
-import{ft as e}from"./index-DEbGNNhA.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-D4l8mBj7.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/it-f72V0Kl8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ja-BhGR4pQS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ko-fHbgl3tQ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/locationSensorDefaults-B639biyx.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/nl-Bw7ikSvM.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-o1FJH7yi.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-Dywt1DRm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-BeLsoYD8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/sv-BV8SnbfK.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/tr-nDTKBQLY.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/uk-Di1zTCN8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/utils-rJTOVBDm.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-rRsYsvW9.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-B7i1_wnq.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DEbGNNhA.js"></script>
+    <script type="module" crossorigin src="/assets/index-D4l8mBj7.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">

Some files were not shown because too many files changed in this diff