Explorar o código

Security hardening (maziggy/bambuddy-security #20)

maziggy hai 1 semana
pai
achega
047fe0df49
Modificáronse 86 ficheiros con 643 adicións e 49 borrados
  1. 234 14
      backend/app/services/github_restore.py
  2. 24 19
      backend/tests/unit/test_github_restore.py
  3. 30 11
      backend/tests/unit/test_github_restore_secret_destination.py
  4. 277 0
      backend/tests/unit/test_github_restore_validation.py
  5. 5 0
      frontend/src/i18n/locales/de.ts
  6. 5 0
      frontend/src/i18n/locales/en.ts
  7. 5 0
      frontend/src/i18n/locales/es.ts
  8. 5 0
      frontend/src/i18n/locales/fr.ts
  9. 5 0
      frontend/src/i18n/locales/it.ts
  10. 5 0
      frontend/src/i18n/locales/ja.ts
  11. 5 0
      frontend/src/i18n/locales/ko.ts
  12. 5 0
      frontend/src/i18n/locales/nl.ts
  13. 5 0
      frontend/src/i18n/locales/pt-BR.ts
  14. 5 0
      frontend/src/i18n/locales/ru.ts
  15. 5 0
      frontend/src/i18n/locales/sv.ts
  16. 5 0
      frontend/src/i18n/locales/tr.ts
  17. 5 0
      frontend/src/i18n/locales/uk.ts
  18. 5 0
      frontend/src/i18n/locales/zh-CN.ts
  19. 5 0
      frontend/src/i18n/locales/zh-TW.ts
  20. 0 0
      static/assets/AdditionalSection-DWpbEZ_o.js
  21. 0 0
      static/assets/AmsUnitCard-BTQMtxUk.js
  22. 0 0
      static/assets/ArchivesPage-BwhPSruG.js
  23. 0 0
      static/assets/AssignToAmsModal-5EcKc-rs.js
  24. 0 0
      static/assets/CamWallPage-Cl5y2JG0.js
  25. 0 0
      static/assets/CameraDiagnoseModal-CrxuO1K_.js
  26. 0 0
      static/assets/CameraPage-C1xBgOXF.js
  27. 0 0
      static/assets/CameraWall-CJNqVbxn.js
  28. 0 0
      static/assets/ColumnConfigModal-C-L7BBk3.js
  29. 0 0
      static/assets/ConfigureAmsSlotModal-7byj-ncs.js
  30. 0 0
      static/assets/ConnectAuthorizePage-DMZ3JfhW.js
  31. 0 0
      static/assets/ContextMenu-KGF8SASR.js
  32. 0 0
      static/assets/ExternalLinkPage-DGsWgJBL.js
  33. 0 1
      static/assets/FileManagerPage-9rmJzIJi.js
  34. 0 0
      static/assets/FileUploadModal-i6DJAJPO.js
  35. 0 0
      static/assets/FinancePage-Dt-y6WKC.js
  36. 0 0
      static/assets/GCodeViewerPage-CHQIvG5j.js
  37. 0 0
      static/assets/GroupEditPage-BXBF78Yr.js
  38. 0 0
      static/assets/ImagePreviewModal-D8ppD09A.js
  39. 0 0
      static/assets/InventoryPage-BQqqhUV0.js
  40. 0 0
      static/assets/LibraryTrashPage-cW75xy_P.js
  41. 0 0
      static/assets/LoginPage-5p0Vq5Un.js
  42. 0 0
      static/assets/MaintenancePage-DgNn6ysw.js
  43. 0 0
      static/assets/ModelSourcesPage-DpTllUyT.js
  44. 0 0
      static/assets/NotificationsPage-BVkI2U2e.js
  45. 0 1
      static/assets/PdfPreviewModal-DASry-y_.js
  46. 0 0
      static/assets/PhotoGalleryModal-CvRohKjV.js
  47. 0 0
      static/assets/PrinterLocationsPage-DavqZawn.js
  48. 0 0
      static/assets/PrintersPage-pSeq4gpq.js
  49. 0 0
      static/assets/ProfilesPage-DQ_3Icvs.js
  50. 0 0
      static/assets/ProjectDetailPage-BFJofJFD.js
  51. 0 0
      static/assets/ProjectsPage-BWOMzbtQ.js
  52. 0 0
      static/assets/QueuePage-CDYL8FW3.js
  53. 0 0
      static/assets/RichTextEditor-kVWuMNiq.js
  54. 0 0
      static/assets/SettingsPage-C9814uvC.js
  55. 0 0
      static/assets/SetupPage-BcCwxrij.js
  56. 0 0
      static/assets/SliceModal-BNZ90VBd.js
  57. 0 0
      static/assets/SpoolBuddyAmsPage-ClnYQW1L.js
  58. 0 0
      static/assets/SpoolBuddyCalibrationPage-BUFQsQ9B.js
  59. 0 0
      static/assets/SpoolBuddyDashboard-Czx61QyD.js
  60. 0 0
      static/assets/SpoolBuddyInventoryPage-DVgXVkl-.js
  61. 1 1
      static/assets/SpoolBuddySettingsPage-D3dNbkNM.js
  62. 0 0
      static/assets/SpoolBuddyWriteTagPage-CDWcjmWm.js
  63. 0 0
      static/assets/SpreadsheetPreviewModal-BLtDmVMO.js
  64. 0 0
      static/assets/StatsPage-cW5bZL4u.js
  65. 0 0
      static/assets/StreamOverlayPage-BEFn7BMV.js
  66. 0 0
      static/assets/SystemInfoPage-CXO94OdL.js
  67. 0 0
      static/assets/de-MhLckxCK.js
  68. 0 0
      static/assets/es-OQ0M1RNP.js
  69. 0 0
      static/assets/fr-B4qii4Uk.js
  70. 0 0
      static/assets/index-fwznsy2U.js
  71. 1 1
      static/assets/inventorySearch-BXbelz8C.js
  72. 0 0
      static/assets/it-BnbUTPSA.js
  73. 0 0
      static/assets/ja-ChUAPWxI.js
  74. 0 0
      static/assets/ko-BY8XJxha.js
  75. 0 0
      static/assets/locationSensorDefaults-C8PVRpPl.js
  76. 0 0
      static/assets/nl-Bv9vDy4k.js
  77. 0 0
      static/assets/pdf-C-3Vb5g-.js
  78. 0 0
      static/assets/pt-BR-831utWem.js
  79. 0 0
      static/assets/ru-CD4S12VG.js
  80. 0 0
      static/assets/sv-C3HTHiY0.js
  81. 0 0
      static/assets/tr-lkcLBFlT.js
  82. 0 0
      static/assets/uk-BaXFeBlZ.js
  83. 0 0
      static/assets/utils-CZCUHebr.js
  84. 0 0
      static/assets/zh-CN-9h5lIylq.js
  85. 0 0
      static/assets/zh-TW-drlk8VrC.js
  86. 1 1
      static/index.html

+ 234 - 14
backend/app/services/github_restore.py

@@ -40,9 +40,11 @@ from dataclasses import dataclass, field as dataclasses_field
 from datetime import datetime, timezone
 
 import httpx
+from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
 from backend.app.core.database import async_session
 from backend.app.core.printer_scope import drop_printers_from_obico
 from backend.app.models.archive import PrintArchive
@@ -53,9 +55,11 @@ from backend.app.models.settings import Settings
 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.auth import SMTPSettings
 from backend.app.schemas.github_backup import RestoreCategory
-from backend.app.schemas.settings import AppSettings
+from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.git_providers.factory import get_provider_backend
+from backend.app.services.model_providers.manyfold.config import normalize_url
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.secret_fields import SETTINGS_SECRETS, SMTP_DESTINATIONS, is_secret_setting_key
 
@@ -198,6 +202,102 @@ def _is_protected_setting_key(key: str) -> bool:
     return key in _PROTECTED_SETTING_KEYS or key.lower().startswith(_PROTECTED_SETTING_PREFIXES)
 
 
+# A user's email code sign-in switch. Per user, and it decides how that user
+# signs in: never taken from a backup, whatever its value.
+_TWO_FACTOR_KEY = re.compile(r"^user_\d+_email_2fa_enabled$")
+
+# Settings kept outside AppSettings that a restore writes, each checked as the
+# editor that owns it checks it: (model, field) for a field of that editor's
+# request model. Anything else outside AppSettings is runtime state, a login's
+# account state, automation that needs its own permission (the purge
+# schedules), a migration flag or unknown, and is not restored.
+_EXTRA_RESTORABLE_KEYS: dict[str, tuple[type[BaseModel], str]] = {
+    key: (SMTPSettings, key)
+    for key in (
+        "smtp_host",
+        "smtp_port",
+        "smtp_security",
+        "smtp_auth_enabled",
+        "smtp_from_email",
+        "smtp_from_name",
+        "smtp_username",
+        "smtp_use_tls",
+    )
+}
+
+# Settings that decide where Bambuddy sends something: the links in its emails
+# (password reset and welcome links carry a sign-in token), the mail server
+# itself, the full database backups, printer status, camera snapshots, model
+# files, inventory. A backup naming another address would send it there, so a
+# value that differs from this instance's is kept as it is here.
+_ADDRESS_KEYS = (
+    "external_url",
+    "smtp_host",
+    "smtp_port",
+    "smtp_security",
+    "smtp_use_tls",
+    "local_backup_path",
+    "mqtt_broker",
+    "mqtt_port",
+    "mqtt_use_tls",
+    "ha_url",
+    "obico_ml_url",
+    "bambuddy_internal_url",
+    "orcaslicer_api_url",
+    "bambu_studio_api_url",
+    "spoolman_url",
+    "manyfold_url",
+)
+
+# Restored without a model check: the Manyfold editor checks the URL with
+# normalize_url and takes the client id as text.
+_MANYFOLD_URL_KEY = "manyfold_url"
+_MANYFOLD_CLIENT_ID_KEY = "manyfold_client_id"
+
+# The settings whose saved null (the text "None") the settings reader reads as
+# null. Anywhere else it is refused, as the editor refuses a null.
+_READS_A_SAVED_NULL = frozenset({"ams_temp_alarm", "default_printer_id", "open_in_slicer"})
+
+
+def _stored_form(value: object) -> str:
+    """A checked value as its editor stores it."""
+    if isinstance(value, bool):
+        return "true" if value else "false"
+    if value is None:
+        return "None"
+    return str(value)
+
+
+def _checked_setting(key: str, value: object) -> tuple[bool | None, str]:
+    """A restored value checked as its editor checks it.
+
+    ``(True, stored form)`` if the editor would accept it, ``(False, "")`` if
+    not, ``(None, "")`` if no editor writes the key, so it is not restored.
+    The stored form is what the editor would write: a value the check only
+    coerced ("587.0", "yes") is written as the editor writes it.
+    """
+    candidate = None if value == "None" and key in _READS_A_SAVED_NULL else value
+    try:
+        if key in AppSettingsUpdate.model_fields:
+            checked = getattr(AppSettingsUpdate.model_validate({key: candidate}), key)
+            # Checked where it is used rather than where it is saved: checked
+            # here as well, before it is written.
+            if key == "spoolman_url" and checked:
+                assert_safe_spoolman_url(str(checked))
+            return True, _stored_form(checked)
+        if key in _EXTRA_RESTORABLE_KEYS:
+            model, field = _EXTRA_RESTORABLE_KEYS[key]
+            instance = model.__pydantic_validator__.validate_assignment(model.model_construct(), field, candidate)
+            return True, _stored_form(getattr(instance, field))
+        if key == _MANYFOLD_URL_KEY:
+            return True, normalize_url(str(candidate)) if candidate else ""
+    except ValueError:
+        return False, ""
+    if key == _MANYFOLD_CLIENT_ID_KEY:
+        return True, str(value)
+    return None, ""
+
+
 # There used to be an ``_is_skipped_setting_key`` here, the union of the two
 # predicates above, shared by the preview and the restore so neither could drift
 # from the other. It is gone because a name is no longer enough to decide: the
@@ -325,19 +425,37 @@ class _SettingsPlan:
     protected: tuple[str, ...] = ()
     companion: tuple[str, ...] = ()
     destination: tuple[str, ...] = ()
+    two_factor: tuple[str, ...] = ()
+    invalid: tuple[str, ...] = ()
+    unknown: tuple[str, ...] = ()
+    # Bambuddy's own one-shot flags (a leading underscore): refused without a
+    # word, as every backup carries them.
+    internal: tuple[str, ...] = ()
+    address: tuple[str, ...] = ()
+    # Each key to write, in the form its editor stores.
+    stored: dict[str, str] = dataclasses_field(default_factory=dict)
 
     @property
-    def refused(self) -> frozenset[str]:
+    def _buckets(self) -> tuple[tuple[str, ...], ...]:
         return (
-            frozenset(self.blocked)
-            | frozenset(self.protected)
-            | frozenset(self.companion)
-            | frozenset(self.destination)
+            self.blocked,
+            self.protected,
+            self.companion,
+            self.destination,
+            self.two_factor,
+            self.invalid,
+            self.unknown,
+            self.internal,
+            self.address,
         )
 
+    @property
+    def refused(self) -> frozenset[str]:
+        return frozenset(key for bucket in self._buckets for key in bucket)
+
     @property
     def refused_count(self) -> int:
-        return len(self.blocked) + len(self.protected) + len(self.companion) + len(self.destination)
+        return sum(len(bucket) for bucket in self._buckets)
 
 
 @dataclass(frozen=True)
@@ -507,6 +625,11 @@ class GitHubRestoreService:
         """
         blocked: list[str] = []
         protected: list[str] = []
+        two_factor: list[str] = []
+        invalid: list[str] = []
+        unknown: list[str] = []
+        internal: list[str] = []
+        stored: dict[str, str] = {}
         # Toggle -> credential for the pairs that survived the payload-only
         # conditions and still need local state to judge.
         candidates: dict[str, str] = {}
@@ -520,12 +643,29 @@ class GitHubRestoreService:
             if _is_protected_setting_key(key):
                 protected.append(key)
                 continue
+            if _TWO_FACTOR_KEY.match(key):
+                two_factor.append(key)
+                continue
+            if key.startswith("_"):
+                internal.append(key)
+                continue
+            # A null is skipped by the restore itself, not refused.
+            if value is not None:
+                valid, form = _checked_setting(key, value)
+                if valid is None:
+                    unknown.append(key)
+                    continue
+                if not valid:
+                    invalid.append(key)
+                    continue
+                stored[key] = form
 
             credential = _COMPANION_CREDENTIALS.get(key)
             if credential is None:
                 continue
             # Turning something *off* is always safe to write.
-            if not _setting_value_is_true(value):
+            # Judged as written: a spelling the check reads as on is stored as on.
+            if not _setting_value_is_true(stored.get(key, value)):
                 continue
             # Expressed as the predicate rather than assumed, so the map cannot
             # go quietly inert if the secret hints are ever edited: a credential
@@ -548,15 +688,31 @@ class GitHubRestoreService:
         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 value is not None
+            and (secrets := _DESTINATION_SECRETS.get(key))
+            and key not in protected
+            and key not in invalid
+            and key not in unknown
+            and key not in internal
         }
 
-        if not candidates and not destinations:
-            return _SettingsPlan(blocked=tuple(blocked), protected=tuple(protected))
+        addresses = [key for key in _ADDRESS_KEYS if key in stored]
+
+        if not candidates and not destinations and not addresses:
+            return _SettingsPlan(
+                blocked=tuple(blocked),
+                protected=tuple(protected),
+                two_factor=tuple(two_factor),
+                invalid=tuple(invalid),
+                unknown=tuple(unknown),
+                internal=tuple(internal),
+                stored=stored,
+            )
 
         # One SELECT covering both halves of every candidate pair.
         wanted = set(candidates) | set(candidates.values()) | set(destinations)
         wanted |= {secret for secrets in destinations.values() for secret in secrets}
+        wanted |= set(addresses)
         rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
         local = {row.key: row.value for row in rows.scalars().all()}
 
@@ -600,14 +756,38 @@ class GitHubRestoreService:
                 # counted as a change.
                 destination.append(key)
                 continue
-            if _as_setting_text(values[key]) != _as_setting_text(current):
+            if _as_setting_text(stored.get(key, values[key])) != _as_setting_text(current):
                 destination.append(key)
 
+        # Compared as written with this instance's value: its row, else the
+        # schema default, else nothing.
+        address = [
+            key
+            for key in addresses
+            if key not in destination
+            and _as_setting_text(stored[key])
+            != _as_setting_text(
+                local[key]
+                if key in local
+                else AppSettings.model_fields[key].get_default(call_default_factory=True)
+                if key in AppSettings.model_fields
+                else None
+            )
+        ]
+        for key in address:
+            del stored[key]
+
         return _SettingsPlan(
             blocked=tuple(blocked),
             protected=tuple(protected),
             companion=tuple(companion),
+            address=tuple(address),
             destination=tuple(destination),
+            two_factor=tuple(two_factor),
+            invalid=tuple(invalid),
+            unknown=tuple(unknown),
+            internal=tuple(internal),
+            stored=stored,
         )
 
     async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
@@ -719,6 +899,7 @@ class GitHubRestoreService:
             # policy keys stay unmentioned on purpose.
             plan = await self._plan_settings(db, values)
             detail = None
+            not_restorable = len(plan.two_factor) + len(plan.invalid) + len(plan.unknown) + len(plan.address)
             if plan.destination:
                 detail = _Detail(
                     "settingsDestinationWillKeep",
@@ -726,6 +907,13 @@ class GitHubRestoreService:
                     "stored here somewhere else; credentials are never restored",
                     {"destination": len(plan.destination)},
                 )
+            elif not_restorable:
+                detail = _Detail(
+                    "settingsNotRestorableWillSkip",
+                    f"{not_restorable} setting(s) will be skipped - values Settings would not accept, or keys a "
+                    "backup does not restore",
+                    {"count": not_restorable},
+                )
             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
@@ -1667,13 +1855,13 @@ class GitHubRestoreService:
                 if not overwrite:
                     tally.skipped += 1
                     continue
-                existing.value = str(value)
+                existing.value = plan.stored[key]
                 tally.restored += 1
                 if keys_written is not None:
                     keys_written.add(key)
                 continue
 
-            db.add(Settings(key=key, value=str(value)))
+            db.add(Settings(key=key, value=plan.stored[key]))
             tally.restored += 1
             if keys_written is not None:
                 keys_written.add(key)
@@ -1705,6 +1893,38 @@ class GitHubRestoreService:
                 keys=keys,
                 count=len(plan.companion),
             )
+        if plan.two_factor:
+            tally.note(
+                "settingsTwoFactorSkipped",
+                f"{len(plan.two_factor)} email sign-in code setting(s) skipped - each user switches email codes "
+                "on or off in their own account",
+                count=len(plan.two_factor),
+            )
+        if plan.address:
+            keys = ", ".join(sorted(plan.address))
+            tally.note(
+                "settingsAddressKept",
+                f"{keys} left as they are - they decide where Bambuddy sends data, emails or backups; change them "
+                "in Settings",
+                keys=keys,
+                count=len(plan.address),
+            )
+        if plan.invalid:
+            keys = ", ".join(sorted(plan.invalid))
+            tally.note(
+                "settingsInvalidSkipped",
+                f"{keys} skipped - the value in the backup is not one Settings would accept",
+                keys=keys,
+                count=len(plan.invalid),
+            )
+        if plan.unknown:
+            keys = ", ".join(sorted(plan.unknown))
+            tally.note(
+                "settingsUnknownSkipped",
+                f"{keys} skipped - not settings a backup restores (runtime state, account sessions or unknown keys)",
+                keys=keys,
+                count=len(plan.unknown),
+            )
         if plan.destination:
             keys = ", ".join(sorted(plan.destination))
             tally.note(

+ 24 - 19
backend/tests/unit/test_github_restore.py

@@ -235,13 +235,13 @@ class TestRestoreSettings:
     @pytest.mark.asyncio
     async def test_inserts_missing_keys(self, db_session):
         tally = _CategoryTally()
-        payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
+        payload = {"version": "1.0", "settings": {"currency": "EUR", "language": "de"}}
 
         await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
         await db_session.commit()
 
         rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
-        assert rows == {"currency": "EUR", "theme": "dark"}
+        assert rows == {"currency": "EUR", "language": "de"}
         assert tally.restored == 2
 
     @pytest.mark.asyncio
@@ -428,11 +428,11 @@ class TestCompanionCredentials:
     @pytest.mark.asyncio
     async def test_ha_toggle_is_refused_when_the_environment_has_no_token(self, db_session, monkeypatch):
         monkeypatch.delenv("HA_TOKEN", raising=False)
-        await self._restore(db_session, ha_enabled="true", ha_token="s3cret", ha_url="http://ha.local")
+        await self._restore(db_session, ha_enabled="true", ha_token="s3cret", currency="EUR")
 
         rows = await self._rows(db_session)
         assert "ha_enabled" not in rows
-        assert rows["ha_url"] == "http://ha.local"
+        assert rows["currency"] == "EUR"
 
     @pytest.mark.asyncio
     async def test_a_blank_local_credential_row_is_not_usable(self, db_session):
@@ -464,12 +464,12 @@ class TestCompanionCredentials:
         companion one were all counted as ``skipped`` despite never being in the
         preview's count.
         """
-        db_session.add(Settings(key="theme", value="light"))
+        db_session.add(Settings(key="language", value="en"))
         await db_session.commit()
 
         values = {
             "currency": "EUR",  # inserted    -> restored
-            "theme": "dark",  # exists, overwrite off -> skipped
+            "language": "de",  # exists, overwrite off -> skipped
             "low_stock_threshold": None,  # no value    -> skipped
             "": "junk",  # unusable key -> failed
             "bambu_cloud_token": "x",  # blocked     -> refused
@@ -666,9 +666,11 @@ class TestCompanionCredentials:
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize("value", ["1", "on", "yes"])
-    async def test_spellings_no_reader_treats_as_on_are_written(self, db_session, value):
+    async def test_spellings_the_editor_reads_as_on_are_judged_as_on(self, db_session, value):
+        """The restore writes them as the editor stores them, "true", so the
+        rule judges them as on: no usable token here, the switch stays off."""
         await self._restore(db_session, prometheus_enabled=value, prometheus_token="s3cret")
-        assert (await self._rows(db_session))["prometheus_enabled"] == value
+        assert "prometheus_enabled" not in await self._rows(db_session)
 
     @pytest.mark.asyncio
     async def test_ha_token_in_the_environment_counts_as_usable(self, db_session, monkeypatch):
@@ -708,7 +710,8 @@ class TestCompanionCredentials:
     async def test_plan_leaves_unusable_key_names_in_no_bucket(self, db_session):
         """They are the restore's ``failed``, not a refusal."""
         plan = await _service()._plan_settings(db_session, {"": "x", 7: "y", "currency": "EUR"})
-        assert plan == _SettingsPlan()
+        assert plan.refused == frozenset()
+        assert plan.stored == {"currency": "EUR"}
 
 
 class TestSpoolTagOverwrite:
@@ -3080,12 +3083,12 @@ class TestMqttRelayReconfigure:
 
     @pytest.mark.asyncio
     async def test_restore_settings_reports_the_keys_it_wrote(self, db_session):
-        db_session.add(Settings(key="mqtt_broker", value="old.local"))
+        db_session.add(Settings(key="mqtt_topic_prefix", value="old.local"))
         await db_session.commit()
         written: set[str] = set()
         payload = {
             "settings": {
-                "mqtt_broker": "new.local",
+                "mqtt_topic_prefix": "new.local",
                 "currency": "EUR",
                 "mqtt_password": "leaked",
                 "auth_enabled": "false",
@@ -3098,17 +3101,17 @@ class TestMqttRelayReconfigure:
 
         # Skipped keys are not "written", or a blocked mqtt_password would
         # trigger a pointless reconnect.
-        assert written == {"mqtt_broker", "currency"}
+        assert written == {"mqtt_topic_prefix", "currency"}
 
     @pytest.mark.asyncio
     async def test_keys_skipped_for_overwrite_off_are_not_reported(self, db_session):
-        db_session.add(Settings(key="mqtt_broker", value="old.local"))
+        db_session.add(Settings(key="mqtt_topic_prefix", value="old.local"))
         await db_session.commit()
         written: set[str] = set()
 
         await _service()._restore_settings(
             db_session,
-            {"settings": {"mqtt_broker": "new.local"}},
+            {"settings": {"mqtt_topic_prefix": "new.local"}},
             overwrite=False,
             tally=_CategoryTally(),
             keys_written=written,
@@ -3129,13 +3132,13 @@ class TestMqttRelayReconfigure:
 
         await _service()._restore_settings(
             db_session,
-            {"settings": {"mqtt_enabled": "true", "mqtt_password": "refused", "mqtt_broker": "new.local"}},
+            {"settings": {"mqtt_enabled": "true", "mqtt_password": "refused", "mqtt_topic_prefix": "new.local"}},
             overwrite=True,
             tally=_CategoryTally(),
             keys_written=written,
         )
 
-        assert written == {"mqtt_broker"}
+        assert written == {"mqtt_topic_prefix"}
 
 
 class TestApplyOrdering:
@@ -3236,7 +3239,7 @@ class TestKprofilePhaseFailure:
     leaving the relay pointed at the pre-restore broker.
     """
 
-    _SETTINGS = {"version": "1.0", "settings": {"mqtt_broker": "restored.local", "currency": "EUR"}}
+    _SETTINGS = {"version": "1.0", "settings": {"mqtt_topic_prefix": "restored.local", "currency": "EUR"}}
 
     def _payload(self, profiles=None):
         return {
@@ -3278,7 +3281,9 @@ class TestKprofilePhaseFailure:
 
         assert results[RestoreCategory.SETTINGS.value].restored == 2
         rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
-        assert rows == {"mqtt_broker": "restored.local", "currency": "EUR"}, "committed before the phase that failed"
+        assert rows == {"mqtt_topic_prefix": "restored.local", "currency": "EUR"}, (
+            "committed before the phase that failed"
+        )
 
     @pytest.mark.asyncio
     async def test_the_failure_is_counted_and_explained(self, db_session):
@@ -3315,7 +3320,7 @@ class TestKprofilePhaseFailure:
         assert result["results"][RestoreCategory.SETTINGS.value]["restored"] == 2
         assert result["results"][RestoreCategory.KPROFILES.value]["failed"] == 1
         relay.configure.assert_awaited_once()
-        assert relay.configure.await_args.args[0]["mqtt_broker"] == "restored.local"
+        assert relay.configure.await_args.args[0]["mqtt_topic_prefix"] == "restored.local"
 
     @pytest.mark.asyncio
     async def test_a_failure_before_the_commit_still_reports_nothing_restored(self, db_session):

+ 30 - 11
backend/tests/unit/test_github_restore_secret_destination.py

@@ -49,13 +49,16 @@ async def test_a_changed_broker_is_left_as_it_is_while_the_password_is_kept(db_s
 
 
 @pytest.mark.asyncio
-async def test_without_a_stored_credential_the_destination_is_restored(db_session):
+async def test_without_a_stored_credential_the_destination_is_still_kept(db_session):
+    """No credential to protect, but the broker still decides where printer
+    status goes: kept as an address."""
     await _local(db_session, {"mqtt_broker": "here.local", "mqtt_password": "  "})
 
-    _, written = await _restore(db_session, {"mqtt_broker": "elsewhere.example"})
+    tally, written = await _restore(db_session, {"mqtt_broker": "elsewhere.example"})
 
-    assert await _value(db_session, "mqtt_broker") == "elsewhere.example"
-    assert written == {"mqtt_broker"}
+    assert await _value(db_session, "mqtt_broker") == "here.local"
+    assert written == set()
+    assert "settingsAddressKept" in [note["code"] for note in tally.notes]
 
 
 @pytest.mark.asyncio
@@ -100,14 +103,16 @@ async def test_a_token_does_not_follow_a_changed_url(db_session, monkeypatch, se
 
 
 @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."""
+async def test_with_a_deployment_token_a_changed_url_is_still_kept_as_an_address(db_session, monkeypatch):
+    """The environment's token may follow a URL change in the editor, but a
+    restore never changes where Bambuddy sends things."""
     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"})
+    tally, _written = await _restore(db_session, {"ha_url": "http://elsewhere.example"})
 
-    assert await _value(db_session, "ha_url") == "http://elsewhere.example"
+    assert await _value(db_session, "ha_url") == "http://here.local"
+    assert "settingsAddressKept" in [note["code"] for note in tally.notes]
 
 
 @pytest.mark.asyncio
@@ -163,10 +168,24 @@ async def test_the_manyfold_secret_does_not_follow_a_changed_url(db_session):
 
 
 @pytest.mark.asyncio
-async def test_without_a_stored_smtp_password_the_server_is_restored(db_session):
+async def test_without_a_stored_smtp_password_the_server_is_still_kept(db_session):
+    """An unauthenticated relay: no password to protect, but the server still
+    receives every email, password reset tokens among them."""
     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"}
+    assert await _value(db_session, "smtp_host") == "mail.here.local"
+    assert await _value(db_session, "smtp_security") is None
+    assert written == set()
+
+
+@pytest.mark.asyncio
+async def test_a_destination_is_compared_as_it_would_be_written(db_session):
+    """ " 587" is stored as "587": the same port, so not held back."""
+    await _local(db_session, {"smtp_password": "local-value", "smtp_port": "587"})
+
+    tally, written = await _restore(db_session, {"smtp_port": " 587"})
+
+    assert written == {"smtp_port"}
+    assert "settingsDestinationKept" not in [note["code"] for note in tally.notes]

+ 277 - 0
backend/tests/unit/test_github_restore_validation.py

@@ -0,0 +1,277 @@
+"""A settings restore writes only what the settings editors would accept.
+
+Each value is checked as the editor that owns it checks it. A key no editor
+writes (runtime state, a login's account state, a migration flag, anything
+unknown) is not restored, and neither is a user's email code sign-in flag.
+"""
+
+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 _ADDRESS_KEYS, SETTINGS_PATH, GitHubRestoreService, _CategoryTally
+
+
+async def _restore(db_session, values: dict, local: dict | None = None):
+    if local:
+        db_session.add_all(Settings(key=key, value=value) for key, value in local.items())
+        await db_session.commit()
+    tally, written = _CategoryTally(), set()
+    await GitHubRestoreService()._restore_settings(
+        db_session, {"settings": values}, overwrite=True, tally=tally, keys_written=written
+    )
+    await db_session.commit()
+    rows = (await db_session.execute(select(Settings))).scalars().all()
+    return tally, written, {row.key: row.value for row in rows}
+
+
+def _note(tally: _CategoryTally, code: str) -> dict | None:
+    return next((note for note in tally.notes if note["code"] == code), None)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("key", "refused", "accepted"),
+    [
+        ("ha_url", "http://169.254.169.254/latest", "http://ha.local:8123"),
+        ("bambuddy_internal_url", "ftp://bambuddy.local", "http://bambuddy.local:8000"),
+        ("docker_compose_dir", "/opt/bambuddy; echo x", "/opt/bambuddy"),
+        ("gcode_snippets", "not json", '{"X1C": {"start": ""}}'),
+        ("obico_action", "explode", "notify"),
+        ("energy_price_source", "mars", "fixed"),
+        ("low_stock_threshold", "lots", "20"),
+        ("manyfold_url", "http://169.254.169.254", "http://manyfold.local"),
+        ("spoolman_url", "http://169.254.169.254:7912", "http://spoolman.local:7912"),
+        ("smtp_port", "five", "587"),
+    ],
+)
+async def test_a_value_the_editor_refuses_is_not_restored(db_session, key, refused, accepted):
+    tally, written, stored = await _restore(db_session, {key: refused}, local={key: accepted})
+
+    assert stored[key] == accepted
+    assert written == set()
+    note = _note(tally, "settingsInvalidSkipped")
+    assert note is not None and note["params"]["keys"] == key
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("key", "value"),
+    [
+        ("ha_url", "http://ha.local:8123"),
+        ("docker_compose_dir", "/opt/bambuddy"),
+        ("auto_archive", "true"),
+        ("ams_temp_alarm", "None"),
+        ("energy_cost_per_kwh", "0.31"),
+        ("manyfold_url", "http://manyfold.local"),
+        ("manyfold_client_id", "client"),
+        ("smtp_host", "mail.local"),
+        ("smtp_port", "587"),
+        ("smtp_security", "ssl"),
+        ("smtp_auth_enabled", "false"),
+        ("smtp_from_email", "a@mail.local"),
+        ("smtp_from_name", "Bambuddy"),
+        ("smtp_username", "mailer"),
+        ("smtp_use_tls", "true"),
+    ],
+)
+async def test_a_value_the_editor_accepts_is_restored(db_session, key, value):
+    # An address is restored only as this instance already has it.
+    local = {key: value} if key in _ADDRESS_KEYS else None
+    tally, written, stored = await _restore(db_session, {key: value}, local=local)
+
+    assert stored[key] == value
+    assert written == {key}
+    assert _note(tally, "settingsInvalidSkipped") is None
+    assert _note(tally, "settingsUnknownSkipped") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "key",
+    [
+        "something_from_elsewhere",
+        "debug_logging_enabled",
+        "ams_drying_alarm_latch",
+        "announcements_feed_serial",
+        "stock_alerts_notified",
+        "archive_auto_purge_last_run",
+        "archive_auto_purge_enabled",
+        "archive_auto_purge_days",
+        "archive_auto_purge_stats",
+        "library_trash_retention_days",
+        "library_auto_purge_enabled",
+        "library_auto_purge_days",
+        "library_auto_purge_include_never_printed",
+        "bambu_cloud_email",
+        "orca_cloud_email",
+        "orca_cloud_pending_at",
+        "virtual_printer_model",
+        "ha_env_managed",
+    ],
+)
+async def test_a_key_no_editor_writes_is_not_restored(db_session, key):
+    tally, written, stored = await _restore(db_session, {key: "true", "currency": "EUR"})
+
+    assert key not in stored
+    assert written == {"currency"}
+    note = _note(tally, "settingsUnknownSkipped")
+    assert note is not None and note["params"]["keys"] == key
+
+
+@pytest.mark.asyncio
+async def test_a_users_email_code_sign_in_is_never_switched_by_a_restore(db_session):
+    tally, written, stored = await _restore(
+        db_session, {"user_1_email_2fa_enabled": "false"}, local={"user_1_email_2fa_enabled": "true"}
+    )
+
+    assert stored["user_1_email_2fa_enabled"] == "true"
+    assert written == set()
+    assert _note(tally, "settingsTwoFactorSkipped") is not None
+    assert _note(tally, "settingsUnknownSkipped") is None
+
+
+@pytest.mark.asyncio
+async def test_the_preview_counts_only_what_will_be_written(db_session):
+    parsed = {
+        SETTINGS_PATH: {
+            "settings": {
+                "currency": "EUR",
+                "ha_url": "http://169.254.169.254/",
+                "something_from_elsewhere": "true",
+                "user_1_email_2fa_enabled": "false",
+            }
+        }
+    }
+
+    count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+    assert count == 1
+    assert detail is not None and detail.code == "settingsNotRestorableWillSkip"
+    assert detail.params == {"count": 3}
+
+
+@pytest.mark.asyncio
+async def test_bambuddys_own_one_shot_flags_are_skipped_without_a_word(db_session):
+    """Every backup carries them; naming them on every restore would be noise."""
+    tally, written, stored = await _restore(
+        db_session, {"_backfill_1425_pipeline_permissions_done": "true", "currency": "EUR"}
+    )
+    parsed = {SETTINGS_PATH: {"settings": {"_backfill_1425_pipeline_permissions_done": "true", "currency": "EUR"}}}
+    count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+    assert "_backfill_1425_pipeline_permissions_done" not in stored
+    assert written == {"currency"}
+    assert tally.notes == []
+    assert (count, detail) == (1, None)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("key", ["mqtt_port", "use_slicer_api", "energy_price_source", "auto_archive", "ftp_timeout"])
+async def test_a_saved_null_is_refused_where_the_settings_cannot_read_one(db_session, key):
+    tally, written, stored = await _restore(db_session, {key: "None"})
+
+    assert key not in stored
+    assert _note(tally, "settingsInvalidSkipped") is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("key", ["ams_temp_alarm", "default_printer_id", "open_in_slicer"])
+async def test_a_saved_null_is_restored_where_the_settings_read_one(db_session, key):
+    _tally, written, stored = await _restore(db_session, {key: "None"})
+
+    assert stored[key] == "None"
+    assert written == {key}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("key", "value", "stored_as"),
+    [
+        ("smtp_port", "587.0", "587"),
+        ("smtp_port", " 587", "587"),
+        ("smtp_auth_enabled", "1", "true"),
+        ("mqtt_enabled", "yes", "true"),
+        ("mqtt_port", "1883.0", "1883"),
+        ("auto_archive", "True", "true"),
+        ("smtp_use_tls", "0", "false"),
+    ],
+)
+async def test_a_value_is_written_in_the_form_its_editor_stores(db_session, key, value, stored_as):
+    local = {key: stored_as} if key in _ADDRESS_KEYS else None
+    _tally, written, stored = await _restore(db_session, {key: value}, local=local)
+
+    assert stored[key] == stored_as
+    assert written == {key}
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("local", [{"external_url": "https://bambuddy.here.example"}, None])
+async def test_the_address_sign_in_links_point_to_is_never_changed_by_a_restore(db_session, local):
+    """Password reset and welcome links are built from it: a backup naming
+    another address would send their tokens there."""
+    tally, written, stored = await _restore(
+        db_session, {"external_url": "https://elsewhere.example", "currency": "EUR"}, local=local
+    )
+    parsed = {SETTINGS_PATH: {"settings": {"external_url": "https://elsewhere.example", "currency": "EUR"}}}
+    count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.SETTINGS, parsed)
+
+    assert stored.get("external_url") == (local or {}).get("external_url")
+    assert written == {"currency"}
+    note = _note(tally, "settingsAddressKept")
+    assert note is not None and note["params"]["keys"] == "external_url"
+    assert (count, detail.code if detail else None) == (1, "settingsNotRestorableWillSkip")
+
+
+@pytest.mark.asyncio
+async def test_the_same_sign_in_link_address_restores_quietly(db_session):
+    tally, _written, stored = await _restore(
+        db_session,
+        {"external_url": "https://bambuddy.here.example"},
+        local={"external_url": "https://bambuddy.here.example"},
+    )
+
+    assert stored["external_url"] == "https://bambuddy.here.example"
+    assert _note(tally, "settingsAddressKept") is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("key", "here", "elsewhere"),
+    [
+        ("external_url", "https://bambuddy.here.example", "https://elsewhere.example"),
+        ("smtp_host", "mail.here.example", "mail.elsewhere.example"),
+        ("smtp_port", "587", "25"),
+        ("smtp_security", "starttls", "none"),
+        ("smtp_use_tls", "true", "false"),
+        ("local_backup_path", "/data/backups", "/srv/share"),
+        ("mqtt_broker", "broker.here.example", "broker.elsewhere.example"),
+        ("mqtt_port", "1883", "1884"),
+        ("mqtt_use_tls", "false", "true"),
+        ("ha_url", "http://ha.here.example:8123", "http://ha.elsewhere.example:8123"),
+        ("obico_ml_url", "http://obico.here.example:3333", "http://obico.elsewhere.example:3333"),
+        ("bambuddy_internal_url", "http://bambuddy.here.example:8000", "http://bambuddy.elsewhere.example:8000"),
+        ("orcaslicer_api_url", "http://orca.here.example:3000", "http://orca.elsewhere.example:3000"),
+        ("bambu_studio_api_url", "http://studio.here.example:3000", "http://studio.elsewhere.example:3000"),
+        ("spoolman_url", "http://spoolman.here.example:7912", "http://spoolman.elsewhere.example:7912"),
+        ("manyfold_url", "http://manyfold.here.example", "http://manyfold.elsewhere.example"),
+    ],
+)
+@pytest.mark.parametrize("has_row", [True, False])
+async def test_a_backup_never_changes_where_bambuddy_sends_things(db_session, key, here, elsewhere, has_row):
+    """Mail, backups, printer status, snapshots, model files: an address from a
+    backup is kept as this instance has it, set or not."""
+    tally, written, stored = await _restore(db_session, {key: elsewhere}, local={key: here} if has_row else None)
+
+    assert stored.get(key) == (here if has_row else None)
+    assert written == set()
+    note = _note(tally, "settingsAddressKept")
+    assert note is not None and note["params"]["keys"] == key
+
+
+def test_every_address_key_is_one_a_restore_would_otherwise_write():
+    """An address key nothing checks would be dead weight in the list."""
+    from backend.app.services.github_restore import _checked_setting
+
+    assert set(_ADDRESS_KEYS) == {key for key in _ADDRESS_KEYS if _checked_setting(key, "")[0] is not None}

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

@@ -5676,6 +5676,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: '{{count}} Einstellung(en) werden übersprungen - Werte, die die Einstellungen nicht annehmen würden, oder Schlüssel, die ein Backup nicht wiederherstellt',
         spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
         archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
         kprofilesPrinterCount: 'über {{count}} Drucker',
@@ -5696,6 +5697,10 @@ export default {
         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.',
+        settingsTwoFactorSkipped: '{{count}} Einstellung(en) für Anmeldecodes per E-Mail übersprungen - jeder Benutzer schaltet E-Mail-Codes in seinem eigenen Konto ein oder aus',
+        settingsInvalidSkipped: '{{keys}} übersprungen - der Wert im Backup ist keiner, den die Einstellungen annehmen würden',
+        settingsUnknownSkipped: '{{keys}} übersprungen - keine Einstellungen, die ein Backup wiederherstellt (Laufzeitzustand, Kontositzungen oder unbekannte Schlüssel)',
+        settingsAddressKept: '{{keys}} unverändert gelassen - sie bestimmen, wohin Bambuddy Daten, E-Mails oder Backups sendet; ändere sie in den Einstellungen',
         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',

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

@@ -5725,6 +5725,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: '{{count}} setting(s) will be skipped - values Settings would not accept, or keys a backup does not restore',
         spoolsUsageCount: 'including {{count}} usage record(s)',
         archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
         kprofilesPrinterCount: 'across {{count}} printer(s)',
@@ -5748,6 +5749,10 @@ export default {
         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.',
+        settingsTwoFactorSkipped: '{{count}} email sign-in code setting(s) skipped - each user switches email codes on or off in their own account',
+        settingsInvalidSkipped: '{{keys}} skipped - the value in the backup is not one Settings would accept',
+        settingsUnknownSkipped: '{{keys}} skipped - not settings a backup restores (runtime state, account sessions or unknown keys)',
+        settingsAddressKept: '{{keys}} left as they are - they decide where Bambuddy sends data, emails or backups; change them in Settings',
         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',

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

@@ -5683,6 +5683,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: '{{count}} ajuste(s) se omitirán - valores que Ajustes no aceptaría o claves que una copia de seguridad no restaura',
         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',
@@ -5703,6 +5704,10 @@ export default {
         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.',
+        settingsTwoFactorSkipped: '{{count}} ajuste(s) de códigos de inicio de sesión por correo omitidos - cada usuario activa o desactiva los códigos por correo en su propia cuenta',
+        settingsInvalidSkipped: '{{keys}} omitidos - el valor de la copia de seguridad no es uno que Ajustes aceptaría',
+        settingsUnknownSkipped: '{{keys}} omitidos - no son ajustes que una copia de seguridad restaure (estado en ejecución, sesiones de cuenta o claves desconocidas)',
+        settingsAddressKept: '{{keys}} se han dejado como están - deciden adónde envía Bambuddy datos, correos o copias de seguridad; cámbialos en Ajustes',
         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',

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

@@ -5663,6 +5663,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: '{{count}} paramètre(s) seront ignorés - des valeurs que les Paramètres n\'accepteraient pas, ou des clés qu\'une sauvegarde ne restaure pas',
         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',
@@ -5683,6 +5684,10 @@ export default {
         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.',
+        settingsTwoFactorSkipped: '{{count}} paramètre(s) de code de connexion par e-mail ignorés - chaque utilisateur active ou désactive les codes par e-mail dans son propre compte',
+        settingsInvalidSkipped: '{{keys}} ignorés - la valeur de la sauvegarde n\'est pas une valeur que les Paramètres accepteraient',
+        settingsUnknownSkipped: '{{keys}} ignorés - ce ne sont pas des paramètres qu\'une sauvegarde restaure (état d\'exécution, sessions de compte ou clés inconnues)',
+        settingsAddressKept: '{{keys}} laissés tels quels - ils déterminent où Bambuddy envoie des données, des e-mails ou des sauvegardes ; modifiez-les dans les Paramètres',
         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',

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

@@ -5662,6 +5662,7 @@ export default {
         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",
+        settingsNotRestorableWillSkip: '{{count}} impostazione/i verranno saltate - valori che le Impostazioni non accetterebbero o chiavi che un backup non ripristina',
         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',
@@ -5682,6 +5683,10 @@ export default {
         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.",
+        settingsTwoFactorSkipped: '{{count}} impostazione/i dei codici di accesso via email saltate - ogni utente attiva o disattiva i codici via email nel proprio account',
+        settingsInvalidSkipped: '{{keys}} saltati - il valore nel backup non è uno che le Impostazioni accetterebbero',
+        settingsUnknownSkipped: '{{keys}} saltati - non sono impostazioni che un backup ripristina (stato di esecuzione, sessioni di account o chiavi sconosciute)',
+        settingsAddressKept: '{{keys}} lasciati invariati - decidono dove Bambuddy invia dati, email o backup; modificali nelle Impostazioni',
         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',

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

@@ -5676,6 +5676,7 @@ export default {
         settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
         settingsCompanionOnlyWillSkip: 'スイッチ {{companion}} 件はオフのままになります - それぞれに必要な認証情報はバックアップから復元できません',
         settingsDestinationWillKeep: '{{destination}} 件の設定はそのままになります - ここに保存された認証情報を別の宛先に送ってしまうためです。認証情報は復元されません',
+        settingsNotRestorableWillSkip: '{{count}} 件の設定はスキップされます - 設定画面が受け付けない値、またはバックアップから復元しないキーです',
         spoolsUsageCount: '使用履歴 {{count}} 件を含む',
         archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
         kprofilesPrinterCount: 'プリンター {{count}} 台分',
@@ -5696,6 +5697,10 @@ export default {
         settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください',
         settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります',
         settingsDestinationKept: '{{keys}} はそのままにしました - このインスタンスには保存済みの認証情報があり、変更するとバックアップのアドレスに送られてしまいます。設定で変更し、認証情報をもう一度入力してください。',
+        settingsTwoFactorSkipped: 'メールのサインインコード設定 {{count}} 件をスキップしました - メールコードのオン・オフは各ユーザーが自分のアカウントで切り替えます',
+        settingsInvalidSkipped: '{{keys}} をスキップしました - バックアップの値は設定画面が受け付ける値ではありません',
+        settingsUnknownSkipped: '{{keys}} をスキップしました - バックアップから復元する設定ではありません(実行時の状態、アカウントのセッション、または不明なキー)',
+        settingsAddressKept: '{{keys}} はそのままにしました - Bambuddy がデータ、メール、バックアップを送る先を決める設定です。設定画面で変更してください',
         settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください',
         kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします',
         kprofilesAckUnreliable: '応答しないプリンターも復元済みとして数えます - プロファイルはプリンター側で確認してください',

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

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

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

@@ -5723,6 +5723,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: '{{count}} instelling(en) worden overgeslagen - waarden die Instellingen niet zou accepteren, of sleutels die een back-up niet herstelt',
         spoolsUsageCount: 'inclusief {{count}} verbruiksrecord(s)',
         archivesMetadataOnly: 'Alleen metadata - 3MF-bestanden en miniaturen zitten niet in een Git-back-up',
         kprofilesPrinterCount: 'over {{count}} printer(s)',
@@ -5746,6 +5747,10 @@ export default {
         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.',
+        settingsTwoFactorSkipped: '{{count}} instelling(en) voor inlogcodes per e-mail overgeslagen - elke gebruiker zet e-mailcodes aan of uit in zijn eigen account',
+        settingsInvalidSkipped: '{{keys}} overgeslagen - de waarde in de back-up is er geen die Instellingen zou accepteren',
+        settingsUnknownSkipped: '{{keys}} overgeslagen - geen instellingen die een back-up herstelt (runtimestatus, accountsessies of onbekende sleutels)',
+        settingsAddressKept: '{{keys}} ongewijzigd gelaten - ze bepalen waar Bambuddy gegevens, e-mails of back-ups naartoe stuurt; wijzig ze in Instellingen',
         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',

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

@@ -5662,6 +5662,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: '{{count}} configuração(ões) serão ignoradas - valores que as Configurações não aceitariam ou chaves que um backup não restaura',
         spoolsUsageCount: 'incluindo {{count}} registros de consumo',
         archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
         kprofilesPrinterCount: 'em {{count}} impressoras',
@@ -5682,6 +5683,10 @@ export default {
         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.',
+        settingsTwoFactorSkipped: '{{count}} configuração(ões) de código de login por e-mail ignoradas - cada usuário ativa ou desativa os códigos por e-mail na própria conta',
+        settingsInvalidSkipped: '{{keys}} ignorados - o valor no backup não é um que as Configurações aceitariam',
+        settingsUnknownSkipped: '{{keys}} ignorados - não são configurações que um backup restaura (estado de execução, sessões de conta ou chaves desconhecidas)',
+        settingsAddressKept: '{{keys}} ficaram como estão - definem para onde o Bambuddy envia dados, e-mails ou backups; altere em Configurações',
         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',

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

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

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

@@ -5721,6 +5721,7 @@ errors: {
         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',
+        settingsNotRestorableWillSkip: '{{count}} inställning(ar) hoppas över - värden som Inställningar inte skulle godta, eller nycklar som en säkerhetskopia inte återställer',
         spoolsUsageCount: 'inklusive {{count}} användningspost(er)',
         archivesMetadataOnly: 'Endast metadata — 3MF-filer och minibilder finns inte i en Git-säkerhetskopia',
         kprofilesPrinterCount: 'över {{count}} skrivare',
@@ -5744,6 +5745,10 @@ errors: {
         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.',
+        settingsTwoFactorSkipped: '{{count}} inställning(ar) för inloggningskoder via e-post hoppades över - varje användare slår på eller av e-postkoder i sitt eget konto',
+        settingsInvalidSkipped: '{{keys}} hoppades över - värdet i säkerhetskopian är inte ett som Inställningar skulle godta',
+        settingsUnknownSkipped: '{{keys}} hoppades över - inte inställningar som en säkerhetskopia återställer (körtillstånd, kontosessioner eller okända nycklar)',
+        settingsAddressKept: '{{keys}} lämnades oförändrade - de avgör vart Bambuddy skickar data, e-post eller säkerhetskopior; ändra dem i Inställningar',
         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',

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

@@ -5651,6 +5651,7 @@ export default {
         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',
+        settingsNotRestorableWillSkip: "{{count}} ayar atlanacak - Ayarlar'ın kabul etmeyeceği değerler veya bir yedeğin geri yüklemediği anahtarlar",
         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',
@@ -5671,6 +5672,10 @@ export default {
         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.",
+        settingsTwoFactorSkipped: '{{count}} e-posta oturum açma kodu ayarı atlandı - her kullanıcı e-posta kodlarını kendi hesabında açar veya kapatır',
+        settingsInvalidSkipped: "{{keys}} atlandı - yedekteki değer Ayarlar'ın kabul edeceği bir değer değil",
+        settingsUnknownSkipped: '{{keys}} atlandı - bir yedeğin geri yüklediği ayarlar değil (çalışma durumu, hesap oturumları veya bilinmeyen anahtarlar)',
+        settingsAddressKept: "{{keys}} olduğu gibi bırakıldı - Bambuddy'nin verileri, e-postaları veya yedekleri nereye gönderdiğini belirler; Ayarlar'dan değiştirin",
         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',

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

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

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

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

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

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AdditionalSection-DWpbEZ_o.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AmsUnitCard-BTQMtxUk.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ArchivesPage-BwhPSruG.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/AssignToAmsModal-5EcKc-rs.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CamWallPage-Cl5y2JG0.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraDiagnoseModal-CrxuO1K_.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraPage-C1xBgOXF.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/CameraWall-CJNqVbxn.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ColumnConfigModal-C-L7BBk3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConfigureAmsSlotModal-7byj-ncs.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ConnectAuthorizePage-DMZ3JfhW.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ContextMenu-KGF8SASR.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ExternalLinkPage-DGsWgJBL.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/FileManagerPage-9rmJzIJi.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FileUploadModal-i6DJAJPO.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/FinancePage-Dt-y6WKC.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GCodeViewerPage-CHQIvG5j.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/GroupEditPage-BXBF78Yr.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ImagePreviewModal-D8ppD09A.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/InventoryPage-BQqqhUV0.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/LibraryTrashPage-cW75xy_P.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/LoginPage-5p0Vq5Un.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/MaintenancePage-DgNn6ysw.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ModelSourcesPage-DpTllUyT.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/NotificationsPage-BVkI2U2e.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
static/assets/PdfPreviewModal-DASry-y_.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PhotoGalleryModal-CvRohKjV.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrinterLocationsPage-DavqZawn.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/PrintersPage-pSeq4gpq.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProfilesPage-DQ_3Icvs.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProjectDetailPage-BFJofJFD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ProjectsPage-BWOMzbtQ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/QueuePage-CDYL8FW3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/RichTextEditor-kVWuMNiq.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SettingsPage-C9814uvC.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SetupPage-BcCwxrij.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SliceModal-BNZ90VBd.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyAmsPage-ClnYQW1L.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-BUFQsQ9B.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyDashboard-Czx61QyD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DVgXVkl-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
static/assets/SpoolBuddySettingsPage-D3dNbkNM.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CDWcjmWm.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BLtDmVMO.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StatsPage-cW5bZL4u.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/StreamOverlayPage-BEFn7BMV.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/SystemInfoPage-CXO94OdL.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/de-MhLckxCK.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/es-OQ0M1RNP.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/fr-B4qii4Uk.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-fwznsy2U.js


+ 1 - 1
static/assets/inventorySearch-teFAZL6T.js → static/assets/inventorySearch-BXbelz8C.js

@@ -1 +1 @@
-import{ft as e}from"./index-BCcru1D3.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-fwznsy2U.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};

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/it-BnbUTPSA.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ja-ChUAPWxI.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ko-BY8XJxha.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/locationSensorDefaults-C8PVRpPl.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/nl-Bv9vDy4k.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pdf-C-3Vb5g-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/pt-BR-831utWem.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/ru-CD4S12VG.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/sv-C3HTHiY0.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/tr-lkcLBFlT.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/uk-BaXFeBlZ.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/utils-CZCUHebr.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/zh-CN-9h5lIylq.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/zh-TW-drlk8VrC.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-BCcru1D3.js"></script>
+    <script type="module" crossorigin src="/assets/index-fwznsy2U.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">

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio