Parcourir la source

Security hardening (maziggy/bambuddy-security #16)

maziggy il y a 1 semaine
Parent
commit
487b48c5af
100 fichiers modifiés avec 2250 ajouts et 82 suppressions
  1. 24 1
      backend/app/api/routes/auth.py
  2. 57 8
      backend/app/api/routes/notifications.py
  3. 13 6
      backend/app/api/routes/obico.py
  4. 30 13
      backend/app/api/routes/settings.py
  5. 92 6
      backend/app/api/routes/smart_plugs.py
  6. 3 0
      backend/app/core/database.py
  7. 8 2
      backend/app/main.py
  8. 3 0
      backend/app/schemas/notification.py
  9. 12 1
      backend/app/schemas/smart_plug.py
  10. 2 1
      backend/app/services/rest_smart_plug.py
  11. 171 0
      backend/app/services/secret_fields.py
  12. 1 1
      backend/tests/integration/test_metrics_api.py
  13. 402 0
      backend/tests/integration/test_notification_provider_secrets.py
  14. 6 4
      backend/tests/integration/test_notify_provider_api.py
  15. 617 0
      backend/tests/integration/test_settings_and_plug_secrets.py
  16. 26 15
      backend/tests/integration/test_settings_api.py
  17. 1 1
      backend/tests/integration/test_smart_plugs_api.py
  18. 41 0
      backend/tests/unit/test_debug_start_logs_no_credentials.py
  19. 174 0
      frontend/src/__tests__/components/AddNotificationModalSecrets.test.tsx
  20. 93 0
      frontend/src/__tests__/components/AddSmartPlugModalSecrets.test.tsx
  21. 67 0
      frontend/src/__tests__/components/FailureDetectionSettingsSecrets.test.tsx
  22. 75 0
      frontend/src/__tests__/components/SmtpLdapPasswordReentry.test.tsx
  23. 124 0
      frontend/src/__tests__/pages/SettingsPageSecrets.test.tsx
  24. 24 0
      frontend/src/__tests__/utils/secrets.test.ts
  25. 8 4
      frontend/src/api/client.ts
  26. 13 8
      frontend/src/components/AddNotificationModal.tsx
  27. 24 2
      frontend/src/components/AddSmartPlugModal.tsx
  28. 12 0
      frontend/src/components/EmailSettings.tsx
  29. 15 3
      frontend/src/components/FailureDetectionSettings.tsx
  30. 10 0
      frontend/src/components/LDAPSettings.tsx
  31. 2 0
      frontend/src/i18n/locales/de.ts
  32. 2 0
      frontend/src/i18n/locales/en.ts
  33. 2 0
      frontend/src/i18n/locales/es.ts
  34. 2 0
      frontend/src/i18n/locales/fr.ts
  35. 2 0
      frontend/src/i18n/locales/it.ts
  36. 2 0
      frontend/src/i18n/locales/ja.ts
  37. 2 0
      frontend/src/i18n/locales/ko.ts
  38. 2 0
      frontend/src/i18n/locales/nl.ts
  39. 2 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 2 0
      frontend/src/i18n/locales/ru.ts
  41. 2 0
      frontend/src/i18n/locales/sv.ts
  42. 2 0
      frontend/src/i18n/locales/tr.ts
  43. 2 0
      frontend/src/i18n/locales/uk.ts
  44. 2 0
      frontend/src/i18n/locales/zh-CN.ts
  45. 2 0
      frontend/src/i18n/locales/zh-TW.ts
  46. 38 0
      frontend/src/pages/SettingsPage.tsx
  47. 30 0
      frontend/src/utils/secrets.ts
  48. 0 0
      static/assets/AdditionalSection-DO6h50lP.js
  49. 0 0
      static/assets/AmsUnitCard-CszVX8f_.js
  50. 0 0
      static/assets/ArchivesPage-RxRtDasL.js
  51. 0 0
      static/assets/AreaChart-CrzxG8-V.js
  52. 0 0
      static/assets/AssignToAmsModal-DNkTh3Ev.js
  53. 1 1
      static/assets/Button-DSQo4CdN.js
  54. 0 0
      static/assets/CamWallPage-7osYva9u.js
  55. 0 0
      static/assets/CameraDiagnoseModal-QROEc70W.js
  56. 0 0
      static/assets/CameraPage-Df-6vgjd.js
  57. 0 0
      static/assets/CameraWall-CDYZxLAy.js
  58. 1 1
      static/assets/Card-DzC7lYtY.js
  59. 0 0
      static/assets/CartesianChart-CVzPCcOl.js
  60. 0 0
      static/assets/ColumnConfigModal-nG02CYJ9.js
  61. 0 0
      static/assets/ConfigureAmsSlotModal-B5JpLtqY.js
  62. 0 0
      static/assets/ConnectAuthorizePage-Bwox01ym.js
  63. 0 0
      static/assets/ContextMenu-DkuBVutl.js
  64. 0 0
      static/assets/CopyButton-CdIISoIf.js
  65. 0 0
      static/assets/ErrorBarContext-DsWJ4g-p.js
  66. 0 0
      static/assets/ExternalLinkPage-CvuJ0aBj.js
  67. 0 1
      static/assets/FileManagerPage-DZp-f_ab.js
  68. 0 0
      static/assets/FileUploadModal-uoGmBXYB.js
  69. 0 0
      static/assets/FinancePage-DkVDZYxm.js
  70. 0 0
      static/assets/GCodeViewerPage-BTfuNr4L.js
  71. 0 0
      static/assets/GcodeToolpathViewer-DkkwF32o.js
  72. 0 0
      static/assets/GroupEditPage-CcDPkJTr.js
  73. 0 0
      static/assets/ImagePreviewModal-B3Joz9jQ.js
  74. 0 0
      static/assets/InventoryPage-CmQHmM_H.js
  75. 0 0
      static/assets/LibraryTrashPage-Ba2VXOXO.js
  76. 0 0
      static/assets/LoginPage-j07E0hxP.js
  77. 0 0
      static/assets/MaintenancePage-CJufmCCZ.js
  78. 0 0
      static/assets/ModelSourcesPage-pwZY5gF4.js
  79. 0 0
      static/assets/NotificationsPage-DLwz-OwK.js
  80. 1 1
      static/assets/OverlayFrame-DZh7brCf.js
  81. 0 1
      static/assets/PdfPreviewModal-BRTIRZjy.js
  82. 0 0
      static/assets/PhotoGalleryModal-CwLjwtm6.js
  83. 0 0
      static/assets/PrinterLocationsPage-CywiwQpW.js
  84. 0 0
      static/assets/PrintersPage-CyweXXCr.js
  85. 0 0
      static/assets/ProfilesPage-n8uaAAkR.js
  86. 0 0
      static/assets/ProjectDetailPage-bFIzw1aE.js
  87. 0 0
      static/assets/ProjectsPage-BqXh0kGk.js
  88. 0 0
      static/assets/QueryClientProvider-CdgSKbu5.js
  89. 0 0
      static/assets/QueuePage-D2ytYqW0.js
  90. 0 0
      static/assets/ReferenceLine-BPolYgdr.js
  91. 0 0
      static/assets/RichTextEditor-WujXqvAD.js
  92. 0 0
      static/assets/SettingsPage-C0bPKlYG.js
  93. 0 0
      static/assets/SettingsPage-HSTTX5Rp.js
  94. 0 0
      static/assets/SetupPage-whT6X_8y.js
  95. 0 0
      static/assets/SliceModal-CRiQqnOp.js
  96. 0 0
      static/assets/SpoolBuddyAmsPage-D9NH6KQX.js
  97. 0 0
      static/assets/SpoolBuddyCalibrationPage-DDpXnuBb.js
  98. 0 0
      static/assets/SpoolBuddyDashboard-BrVMWTwD.js
  99. 0 0
      static/assets/SpoolBuddyInventoryPage-DdGMapW3.js
  100. 1 1
      static/assets/SpoolBuddySettingsPage-Ch0V6WKS.js

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

@@ -896,13 +896,36 @@ async def save_smtp_config(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Save SMTP settings (admin only when auth enabled)."""
+    """Save SMTP settings (admin only when auth enabled).
+
+    An empty password keeps the stored one, which only goes to the server it
+    was entered for: a changed host, port or security needs it entered again
+    (without authentication, the stored password is dropped instead).
+    """
     import logging
 
+    from backend.app.services.secret_fields import SMTP_DESTINATIONS, same_value
+
     logger = logging.getLogger(__name__)
 
+    drop_password = False
+    stored = await get_smtp_settings(db)
+    if stored and stored.smtp_password and not smtp_settings.smtp_password:
+        moved = [f for f in SMTP_DESTINATIONS if not same_value(getattr(smtp_settings, f), getattr(stored, f))]
+        if moved and smtp_settings.smtp_auth_enabled:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"Enter the SMTP password again: {', '.join(moved)} changed",
+            )
+        drop_password = bool(moved)
+
     try:
         await save_smtp_settings(db, smtp_settings)
+        if drop_password:
+            from backend.app.core.db_dialect import upsert_setting
+            from backend.app.models.settings import Settings
+
+            await upsert_setting(db, Settings, "smtp_password", "")
         await db.commit()
         logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
         return {"message": "SMTP settings saved successfully"}

+ 57 - 8
backend/app/api/routes/notifications.py

@@ -11,7 +11,12 @@ from fastapi.responses import FileResponse
 from sqlalchemy import delete, desc, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    ScopedCaller,
+    probe_permissions_if_auth_enabled,
+    require_notification_send,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import ensure_printers_exist
@@ -34,6 +39,12 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.notify_client import NotifyError, notify_credentials
 from backend.app.services.notify_live_activities import notify_live_activities
 from backend.app.services.notify_widgets import notify_widgets
+from backend.app.services.secret_fields import (
+    SecretConfigError,
+    mask_config,
+    merge_config,
+    reject_placeholder,
+)
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.utils.notification_photos import find_notification_photo
 
@@ -90,14 +101,21 @@ async def after_providers_deleted(*, notify: bool) -> None:
     await _resync_reaction_poller()
 
 
+def _stored_config(provider: NotificationProvider) -> dict:
+    return json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+
+
 def _provider_to_dict(provider: NotificationProvider) -> dict:
-    """Convert a NotificationProvider model to a response dictionary."""
+    """Convert a NotificationProvider model to a response dictionary.
+
+    Secrets are write-only: a set one is shown as the placeholder.
+    """
     return {
         "id": provider.id,
         "name": provider.name,
         "provider_type": provider.provider_type,
         "enabled": provider.enabled,
-        "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
+        "config": mask_config(_stored_config(provider)),
         "attach_photo": provider.attach_photo,
         # Print lifecycle events
         "on_print_start": provider.on_print_start,
@@ -195,6 +213,10 @@ async def create_notification_provider(
 ):
     """Create a new notification provider."""
     await ensure_printers_exist(db, [provider_data.printer_id])
+    try:
+        reject_placeholder(provider_data.config)
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
     if provider_data.provider_type.value == "notify":
         try:
             notify_credentials(provider_data.config)
@@ -285,10 +307,30 @@ async def test_notification_config(
     test_request: NotificationTestRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.NOTIFICATIONS_UPDATE)),
 ):
-    """Test notification configuration before saving."""
+    """Test notification configuration before saving.
+
+    With provider_id (an edited provider), the stored secrets fill in the
+    placeholders, for those who may edit providers.
+    """
+    config = test_request.config
+    try:
+        if test_request.provider_id is None:
+            reject_placeholder(config)
+        else:
+            if not can_update:
+                raise HTTPException(status_code=403, detail="Testing a saved provider requires notifications:update")
+            provider = await db.get(NotificationProvider, test_request.provider_id)
+            if provider is None:
+                raise HTTPException(status_code=404, detail="Notification provider not found")
+            config = merge_config(
+                provider.provider_type, _stored_config(provider), test_request.provider_type.value, config
+            )
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
     success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config, db, attach_photo=test_request.attach_photo
+        test_request.provider_type.value, config, db, attach_photo=test_request.attach_photo
     )
 
     return NotificationTestResponse(success=success, message=message)
@@ -596,7 +638,7 @@ async def update_notification_provider(
     if not provider:
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
-    old_config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+    old_config = _stored_config(provider)
     old_type = provider.provider_type
 
     # Update only provided fields
@@ -605,6 +647,13 @@ async def update_notification_provider(
 
     effective_type = update_dict.get("provider_type") or provider.provider_type
     effective_type = getattr(effective_type, "value", effective_type)
+    if effective_type != old_type and update_dict.get("config") is None:
+        raise HTTPException(status_code=400, detail="A new provider type needs its own configuration")
+    if update_dict.get("config") is not None:
+        try:
+            update_dict["config"] = merge_config(old_type, old_config, effective_type, update_dict["config"])
+        except SecretConfigError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from None
     if effective_type == "notify":
         try:
             effective_config = update_dict.get("config", old_config)
@@ -623,7 +672,7 @@ async def update_notification_provider(
             setattr(provider, key, value)
 
     if old_type == "notify":
-        effective_config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        effective_config = _stored_config(provider)
         remove_widgets = (
             provider.provider_type != "notify"
             or not provider.enabled
@@ -649,7 +698,7 @@ async def update_notification_provider(
     await db.refresh(provider)
 
     response = _provider_to_dict(provider)
-    new_config = response["config"]
+    new_config = _stored_config(provider)
     changed = old_type != provider.provider_type or any(
         old_config.get(key) != new_config.get(key) for key in ("device_id", "token")
     )

+ 13 - 6
backend/app/api/routes/obico.py

@@ -2,14 +2,18 @@
 
 import logging
 
-from fastapi import APIRouter, HTTPException, Response
+from fastapi import APIRouter, Depends, HTTPException, Response
 from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.settings import get_setting
 from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER, same_value
 
 logger = logging.getLogger(__name__)
 
@@ -18,7 +22,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
 
 class TestConnectionRequest(BaseModel):
     url: str
-    # Omitted entirely = test with the saved token; "" = test with no token.
+    # Omitted entirely or the placeholder = test with the saved token (against
+    # the saved URL only); "" = test with no token.
     token: str | None = None
 
 
@@ -78,16 +83,18 @@ async def get_printer_status(
 @router.post("/test-connection")
 async def test_connection(
     req: TestConnectionRequest,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
     if not req.url:
         return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
     token = req.token
-    if token is None:
-        # Field omitted entirely — test what the service actually uses.
-        settings = await obico_detection_service._load_settings()
-        token = settings.get("ml_token") or ""
+    if token is None or token == SECRET_PLACEHOLDER:
+        # The saved token, which only goes to the saved URL.
+        if not same_value(req.url, await get_setting(db, "obico_ml_url")):
+            raise HTTPException(status_code=400, detail="Enter the token again: the URL changed")
+        token = await get_setting(db, "obico_ml_token") or ""
     return await obico_detection_service.test_connection(req.url, token)
 
 

+ 30 - 13
backend/app/api/routes/settings.py

@@ -27,6 +27,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services import camera_light
+from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, mask_fields, merge_partial
 
 logger = logging.getLogger(__name__)
 
@@ -34,14 +35,9 @@ router = APIRouter(prefix="/settings", tags=["settings"])
 
 DEFAULT_SETTINGS = AppSettings()
 
-# Sensitive credential fields blanked for API-key callers
-_SENSITIVE_FIELDS_FOR_API_KEY = (
-    "mqtt_password",
-    "ha_token",
-    "prometheus_token",
-    "virtual_printer_access_code",
-    "ldap_bind_password",
-)
+# Sensitive credential fields blanked for API-key callers. Everyone else sees
+# them as the placeholder (write-only secrets, see services/secret_fields).
+_SENSITIVE_FIELDS_FOR_API_KEY = tuple(SETTINGS_SECRETS)
 
 
 async def get_setting(db: AsyncSession, key: str) -> str | None:
@@ -267,8 +263,12 @@ _INT_SETTING_KEYS = frozenset(
 )
 
 
-async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -> AppSettings:
-    """Build the full settings response, scrubbing secrets for API-key callers."""
+async def _build_settings_response(db: AsyncSession, is_api_key: bool = False, mask: bool = True) -> AppSettings:
+    """Build the full settings response.
+
+    Secrets are blank for API-key callers and the placeholder for everyone else;
+    ``mask=False`` is for server-side use only (comparing with an update).
+    """
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
     result = await db.execute(select(Settings))
@@ -309,13 +309,17 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
 
-    # ldap_bind_password is never returned to any caller
-    settings_dict["ldap_bind_password"] = ""
+    # ldap_bind_password is never returned to any caller (mask=False is
+    # server-side only)
+    if mask or is_api_key:
+        settings_dict["ldap_bind_password"] = ""
 
     if is_api_key:
         for field in _SENSITIVE_FIELDS_FOR_API_KEY:
             if field in settings_dict:
                 settings_dict[field] = ""
+    elif mask:
+        settings_dict = mask_fields(settings_dict, SETTINGS_SECRETS)
 
     return AppSettings(**settings_dict)
 
@@ -340,6 +344,19 @@ async def update_settings(
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
 
+    # Write-only secrets: the placeholder keeps the stored value, and a kept
+    # secret does not follow a changed destination.
+    stored = (await _build_settings_response(db, mask=False)).model_dump()
+    secrets = dict(SETTINGS_SECRETS)
+    if stored.get("ha_token_from_env"):
+        # The deployment's own token, not a stored secret to enter again: the
+        # URL may change (the placeholder sent back still writes nothing).
+        secrets["ha_token"] = ()
+    try:
+        update_data = merge_partial(stored, update_data, secrets)
+    except SecretConfigError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from None
+
     # An explicit null for a boolean or numeric setting has no meaning -- these
     # are not clearable -- and would be stored as the literal "None".
     null_keys = sorted(
@@ -459,7 +476,7 @@ async def update_settings(
         except Exception:
             pass  # Don't fail the settings update if MQTT reconfiguration fails
 
-    # Return updated settings (never scrub secrets on PUT — caller has SETTINGS_UPDATE permission)
+    # Return updated settings, secrets as the placeholder
     return await _build_settings_response(db, is_api_key=False)
 
 

+ 92 - 6
backend/app/api/routes/smart_plugs.py

@@ -12,6 +12,7 @@ from backend.app.core.auth import (
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
+    probe_permissions_if_auth_enabled,
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -42,6 +43,15 @@ from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
+from backend.app.services.secret_fields import (
+    REST_PLUG_URLS,
+    SECRET_PLACEHOLDER,
+    SMART_PLUG_SECRETS,
+    SecretConfigError,
+    merge_partial,
+    reject_placeholder,
+    same_value,
+)
 from backend.app.services.smart_plug_manager import publish_plug_state, smart_plug_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.local_time import utcnow_naive
@@ -73,6 +83,10 @@ async def create_smart_plug(
     printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new smart plug."""
+    try:
+        reject_placeholder({field: getattr(data, field) for field in SMART_PLUG_SECRETS})
+    except SecretConfigError as exc:
+        raise HTTPException(400, str(exc)) from None
     # Validate printer_id if provided
     if data.printer_id:
         printer_scope.ensure(data.printer_id)
@@ -148,6 +162,20 @@ async def create_smart_plug(
     return plug
 
 
+async def _plug_for_secret_test(
+    db: AsyncSession, plug_id: int | None, can_update: bool, printer_scope: PrinterScope
+) -> SmartPlug:
+    """The saved plug whose stored secret a connection test may use."""
+    if plug_id is None:
+        raise HTTPException(400, "Enter the secret; nothing is saved for a new plug")
+    if not can_update:
+        raise HTTPException(403, "Testing a saved plug's secret requires smart_plugs:update")
+    plug = await db.get(SmartPlug, plug_id)
+    if plug is None or not printer_scope.allows(plug.printer_id):
+        raise HTTPException(404, "Smart plug not found")
+    return plug
+
+
 def _is_script_plug(plug: SmartPlug) -> bool:
     """Whether the plug is a Home Assistant script rather than a switchable device."""
     return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
@@ -416,20 +444,52 @@ async def get_discovered_tasmota_devices(
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
 async def test_ha_connection(
     request: HATestConnectionRequest,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update_settings: bool = Depends(probe_permissions_if_auth_enabled(Permission.SETTINGS_UPDATE)),
 ):
-    """Test connection to Home Assistant."""
-    result = await homeassistant_service.test_connection(request.url, request.token)
+    """Test connection to Home Assistant.
+
+    The placeholder token tests the stored token, against the stored URL only.
+    """
+    token = request.token
+    if token == SECRET_PLACEHOLDER:
+        if not can_update_settings:
+            raise HTTPException(403, "Testing the saved token requires settings:update")
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        stored = await get_homeassistant_settings(db)
+        if not stored.get("ha_token"):
+            raise HTTPException(400, "Enter the token; none is saved")
+        if not same_value(request.url, stored.get("ha_url")):
+            raise HTTPException(400, "Enter the token again: the URL changed")
+        token = stored["ha_token"]
+    result = await homeassistant_service.test_connection(request.url, token)
     return HATestConnectionResponse(**result)
 
 
 @router.post("/rest/test-connection", response_model=RESTTestConnectionResponse)
 async def test_rest_connection(
     request: RESTTestConnectionRequest,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Test connection to a REST/HTTP endpoint."""
-    result = await rest_smart_plug_service.test_connection(request.url, request.method, request.headers)
+    """Test connection to a REST/HTTP endpoint.
+
+    With plug_id, the placeholder headers are the plug's stored headers, for
+    the plug's own URLs only.
+    """
+    headers = request.headers
+    if headers == SECRET_PLACEHOLDER:
+        plug = await _plug_for_secret_test(db, request.plug_id, can_update, printer_scope)
+        if not plug.rest_headers:
+            raise HTTPException(400, "Enter the headers; none are saved")
+        if not any(same_value(request.url, getattr(plug, field)) for field in REST_PLUG_URLS):
+            raise HTTPException(400, "Enter the headers again: the URL is not one of the plug's")
+        headers = plug.rest_headers
+    result = await rest_smart_plug_service.test_connection(request.url, request.method, headers)
     return RESTTestConnectionResponse(**result)
 
 
@@ -517,6 +577,17 @@ async def update_smart_plug(
 
     update_data = data.model_dump(exclude_unset=True)
 
+    # Write-only secrets: the placeholder keeps the stored value, and a kept
+    # secret does not follow a changed address or plug type.
+    stored = {
+        field: getattr(plug, field)
+        for field in {*SMART_PLUG_SECRETS, *(f for fs in SMART_PLUG_SECRETS.values() for f in fs)}
+    }
+    try:
+        update_data = merge_partial(stored, update_data, SMART_PLUG_SECRETS)
+    except SecretConfigError as exc:
+        raise HTTPException(400, str(exc)) from None
+
     # Validate new printer_id if being changed
     if "printer_id" in update_data and update_data["printer_id"]:
         new_printer_id = update_data["printer_id"]
@@ -827,13 +898,28 @@ async def get_plug_status(
 @router.post("/test-connection")
 async def test_connection(
     data: SmartPlugTestConnection,
+    db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    can_update: bool = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Test connection to a Tasmota device."""
+    """Test connection to a Tasmota device.
+
+    With plug_id, the placeholder password is the plug's stored password, for
+    the plug's own address only.
+    """
+    password = data.password
+    if password == SECRET_PLACEHOLDER:
+        plug = await _plug_for_secret_test(db, data.plug_id, can_update, printer_scope)
+        if not plug.password:
+            raise HTTPException(400, "Enter the password; none is saved")
+        if not same_value(data.ip_address, plug.ip_address):
+            raise HTTPException(400, "Enter the password again: the address changed")
+        password = plug.password
     result = await tasmota_service.test_connection(
         data.ip_address,
         data.username,
-        data.password,
+        password,
     )
 
     if not result["success"]:

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

@@ -149,6 +149,9 @@ def _create_engine():
     eng = create_async_engine(
         settings.database_url,
         echo=settings.debug,
+        # The debug SQL echo shows statements, never their values: those
+        # include every secret saved and every password hash.
+        hide_parameters=True,
         **kwargs,
     )
     if is_sqlite():

+ 8 - 2
backend/app/main.py

@@ -397,11 +397,17 @@ from backend.app.core.logging_filters import QueryTokenRedactFilter  # noqa: E40
 for _uvicorn_logger in ("uvicorn.access", "uvicorn.error"):
     logging.getLogger(_uvicorn_logger).addFilter(QueryTokenRedactFilter())
 
+# These stay at WARNING even in debug mode: at INFO/DEBUG httpx/httpcore log
+# full request URLs, and some carry credentials (bot tokens, webhook URLs, API
+# keys in the query). Same as the runtime log-level toggle in support.py.
+logging.getLogger("httpcore").setLevel(logging.WARNING)
+logging.getLogger("httpx").setLevel(logging.WARNING)
+# aiosqlite logs every statement with its parameters at DEBUG.
+logging.getLogger("aiosqlite").setLevel(logging.WARNING)
+
 # Reduce noise from third-party libraries in production
 if not app_settings.debug:
     logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
-    logging.getLogger("httpcore").setLevel(logging.WARNING)
-    logging.getLogger("httpx").setLevel(logging.WARNING)
     logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
 logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)

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

@@ -344,6 +344,9 @@ class NotificationTestRequest(BaseModel):
     attach_photo: bool = Field(
         default=True, description="Include a sample photo in the test, mirroring the provider's own toggle"
     )
+    provider_id: int | None = Field(
+        default=None, description="The saved provider being edited: its stored secrets fill in the placeholders"
+    )
 
 
 class NotificationTestResponse(BaseModel):

+ 12 - 1
backend/app/schemas/smart_plug.py

@@ -1,9 +1,10 @@
 from datetime import datetime
 from typing import Literal
 
-from pydantic import BaseModel, Field, model_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER
 
 
 class SmartPlugBase(BaseModel):
@@ -206,6 +207,12 @@ class SmartPlugResponse(SmartPlugBase):
     class Config:
         from_attributes = True
 
+    @field_validator("password", "rest_headers", mode="after")
+    @classmethod
+    def _write_only(cls, value: str | None) -> str | None:
+        """Secrets are write-only: a set one is shown as the placeholder."""
+        return SECRET_PLACEHOLDER if value else value
+
 
 class SmartPlugControl(BaseModel):
     action: Literal["on", "off", "toggle"]
@@ -236,6 +243,8 @@ class SmartPlugTestConnection(BaseModel):
     ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
     username: str | None = None
     password: str | None = None
+    # The saved plug being edited: its stored password fills in the placeholder.
+    plug_id: int | None = None
 
 
 # Home Assistant schemas
@@ -278,6 +287,8 @@ class RESTTestConnectionRequest(BaseModel):
     url: str = Field(..., min_length=1)
     method: str = Field(default="GET")
     headers: str | None = None  # JSON string of custom headers
+    # The saved plug being edited: its stored headers fill in the placeholder.
+    plug_id: int | None = None
 
 
 class RESTTestConnectionResponse(BaseModel):

+ 2 - 1
backend/app/services/rest_smart_plug.py

@@ -65,7 +65,8 @@ class RESTSmartPlugService:
             if isinstance(headers, dict):
                 return {str(k): str(v) for k, v in headers.items()}
         except (json.JSONDecodeError, TypeError):
-            logger.warning("Failed to parse REST headers JSON: %s", headers_json)
+            # Not the value: headers carry credentials.
+            logger.warning("Failed to parse REST headers JSON; sending none")
         return {}
 
     @staticmethod

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

@@ -0,0 +1,171 @@
+"""Write-only secrets: notification provider configs, settings, smart plugs.
+
+Responses show a set secret as SECRET_PLACEHOLDER. A config sent back with
+the placeholder keeps the stored value, an empty value clears it, and anything
+else replaces it. A stored secret is only used for the destination it was
+entered for: a config that changes the destination (or the provider type) must
+carry the secret again, and a type change must bring a config of its own.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+SECRET_PLACEHOLDER = "********"
+
+# The config fields that hold a credential, per provider type. For Discord and
+# the generic webhook the URL itself is commonly the credential.
+SECRET_FIELDS: dict[str, tuple[str, ...]] = {
+    "callmebot": ("apikey",),
+    "ntfy": ("auth_token",),
+    "pushover": ("user_key", "app_token"),
+    "telegram": ("bot_token",),
+    "email": ("password",),
+    "discord": ("webhook_url",),
+    "webhook": ("webhook_url", "auth_header"),
+    "bark": ("device_key",),
+    "gotify": ("app_token",),
+    "notify": ("token",),
+    "homeassistant": (),
+}
+
+# Every secret field name of every type. Responses mask these whatever the
+# provider's type, so a key left over from another type is never shown.
+ALL_SECRET_FIELDS = frozenset(field for fields in SECRET_FIELDS.values() for field in fields)
+
+# The config fields that decide where the secrets are sent. Types not listed
+# send to a fixed service.
+DESTINATION_FIELDS: dict[str, tuple[str, ...]] = {
+    "email": ("smtp_server", "smtp_port", "security"),
+    "ntfy": ("server",),
+    "bark": ("server",),
+    "gotify": ("server",),
+    "webhook": ("webhook_url",),
+}
+
+
+class SecretConfigError(ValueError):
+    """A config that would reuse a stored secret where it may not."""
+
+
+def mask_config(config: dict[str, Any]) -> dict[str, Any]:
+    """The config as a response shows it: every set secret as the placeholder."""
+    return {key: (SECRET_PLACEHOLDER if key in ALL_SECRET_FIELDS and value else value) for key, value in config.items()}
+
+
+def same_value(a: Any, b: Any) -> bool:
+    """Equal as the edit dialog sends them back: as text, None as empty.
+
+    Not stripped: a sender compares the exact value (" starttls" is not TLS).
+    """
+    return ("" if a is None else str(a)) == ("" if b is None else str(b))
+
+
+# What a sender uses for a destination field that is not in the config at all.
+_SENDER_DEFAULTS: dict[tuple[str, str], str] = {
+    ("email", "security"): "starttls",
+}
+
+
+def _effective(provider_type: str, config: dict[str, Any], field: str) -> Any:
+    if field in config:
+        return config[field]
+    return _SENDER_DEFAULTS.get((provider_type, field))
+
+
+def has_placeholder(config: dict[str, Any]) -> bool:
+    return any(value == SECRET_PLACEHOLDER for value in config.values())
+
+
+def reject_placeholder(config: dict[str, Any]) -> None:
+    """A config with nothing stored behind it (a new provider, a test of one)."""
+    if has_placeholder(config):
+        raise SecretConfigError("Enter the secret values; nothing is stored for this provider yet")
+
+
+def merge_config(
+    stored_type: str,
+    stored: dict[str, Any],
+    new_type: str,
+    incoming: dict[str, Any],
+) -> dict[str, Any]:
+    """The config to use: the placeholder replaced by the stored secret.
+
+    Refused when a placeholder would carry a stored secret to another provider
+    type or another destination.
+    """
+    kept = [key for key, value in incoming.items() if value == SECRET_PLACEHOLDER]
+    if not kept:
+        return dict(incoming)
+    if new_type != stored_type:
+        raise SecretConfigError(f"Enter {', '.join(kept)} again for the new provider type")
+    unknown = [key for key in kept if key not in ALL_SECRET_FIELDS or not stored.get(key)]
+    if unknown:
+        raise SecretConfigError(f"Enter {', '.join(unknown)}; there is no stored value to keep")
+    moved = [
+        field
+        for field in DESTINATION_FIELDS.get(stored_type, ())
+        if incoming.get(field) != SECRET_PLACEHOLDER
+        and not same_value(_effective(stored_type, incoming, field), _effective(stored_type, stored, field))
+    ]
+    if moved:
+        raise SecretConfigError(f"Enter {', '.join(kept)} again: {', '.join(moved)} changed")
+    return {key: (stored[key] if value == SECRET_PLACEHOLDER else value) for key, value in incoming.items()}
+
+
+# Settings secrets and the settings that decide where each is sent.
+SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
+    "mqtt_password": ("mqtt_broker", "mqtt_port", "mqtt_use_tls"),
+    "ha_token": ("ha_url",),
+    "obico_ml_token": ("obico_ml_url",),
+    "prometheus_token": (),
+    "virtual_printer_access_code": (),
+    "ldap_bind_password": ("ldap_server_url", "ldap_security"),
+}
+
+REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
+
+# Smart plug secrets and the fields that decide where each is sent.
+SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
+    "password": ("plug_type", "ip_address"),
+    "rest_headers": ("plug_type", *REST_PLUG_URLS),
+}
+
+
+def mask_fields(values: dict[str, Any], fields: Any) -> dict[str, Any]:
+    """values with each set field of ``fields`` shown as the placeholder."""
+    return {key: (SECRET_PLACEHOLDER if key in fields and value else value) for key, value in values.items()}
+
+
+def merge_partial(
+    stored: dict[str, Any],
+    update: dict[str, Any],
+    secrets: dict[str, tuple[str, ...]],
+) -> dict[str, Any]:
+    """A partial update with the placeholder resolved: the secret is kept.
+
+    A secret is kept when it is sent as the placeholder or not sent at all.
+    Refused when a kept secret's destination changes, or the placeholder
+    stands for a secret that is not stored.
+    """
+    result = dict(update)
+    for secret, destinations in secrets.items():
+        sent = secret in update
+        if sent and update[secret] == SECRET_PLACEHOLDER:
+            if not stored.get(secret):
+                raise SecretConfigError(f"Enter {secret}; there is no stored value to keep")
+            del result[secret]
+        elif sent:
+            continue
+        if not stored.get(secret):
+            continue
+        moved = [
+            field for field in destinations if field in update and not same_value(update[field], stored.get(field))
+        ]
+        if moved:
+            raise SecretConfigError(f"Enter {secret} again: {', '.join(moved)} changed")
+    return result
+
+
+# The SMTP settings that decide where the stored SMTP password is sent.
+SMTP_DESTINATIONS = ("smtp_host", "smtp_port", "smtp_security")

+ 1 - 1
backend/tests/integration/test_metrics_api.py

@@ -129,7 +129,7 @@ class TestMetricsAPI:
         settings = response.json()
 
         assert settings["prometheus_enabled"] is True
-        assert settings["prometheus_token"] == "mytoken"
+        assert settings["prometheus_token"] == "********"  # write-only
 
         # Disable and verify
         await async_client.put("/api/v1/settings/", json={"prometheus_enabled": False})

+ 402 - 0
backend/tests/integration/test_notification_provider_secrets.py

@@ -0,0 +1,402 @@
+"""Notification provider secrets are write-only.
+
+Provider responses return every secret field as a fixed placeholder. Sending
+the placeholder back keeps the stored value, an empty value clears it, and a
+stored secret only goes to the destination it was entered for: changing the
+destination needs the secret entered again. Testing an edited provider fills
+in the stored secrets for those who may edit it.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.notification import NotificationProvider
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+M = SECRET_PLACEHOLDER
+
+# provider type -> (config with every secret set, its secret fields)
+CONFIGS = {
+    "callmebot": ({"phone": "+100", "apikey": "cmb-key"}, ["apikey"]),
+    "ntfy": ({"server": "https://ntfy.example", "topic": "t", "auth_token": "ntfy-tok"}, ["auth_token"]),
+    "pushover": ({"user_key": "po-user", "app_token": "po-app", "priority": "0"}, ["user_key", "app_token"]),
+    "telegram": ({"bot_token": "123:tg", "chat_id": "42"}, ["bot_token"]),
+    "email": (
+        {
+            "smtp_server": "smtp.example",
+            "smtp_port": "587",
+            "security": "starttls",
+            "auth_enabled": "true",
+            "username": "u",
+            "password": "mail-pw",
+            "from_email": "a@example.com",
+            "to_email": "b@example.com",
+        },
+        ["password"],
+    ),
+    "discord": ({"webhook_url": "https://discord.example/api/webhooks/1/abc"}, ["webhook_url"]),
+    "webhook": (
+        {"webhook_url": "https://hooks.example/x?key=k", "auth_header": "Bearer wh", "payload_format": "generic"},
+        ["webhook_url", "auth_header"],
+    ),
+    "bark": ({"device_key": "bark-key", "server": "https://bark.example"}, ["device_key"]),
+    "gotify": ({"server": "https://gotify.example", "app_token": "go-tok"}, ["app_token"]),
+    "notify": ({"device_id": "IO12345678901234", "token": "notify-tok"}, ["token"]),
+    "homeassistant": ({"service": "notify.mobile"}, []),
+}
+
+
+async def _admin(client: AsyncClient) -> dict[str, str]:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "nsadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "nsadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _user(client: AsyncClient, admin: dict[str, str], name: str, permissions: list[str]) -> dict[str, str]:
+    group = await client.post("/api/v1/groups/", headers=admin, json={"name": f"g_{name}", "permissions": permissions})
+    assert group.status_code in (200, 201), group.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": name, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await client.post("/api/v1/auth/login", json={"username": name, "password": "UserPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _create(client: AsyncClient, headers: dict[str, str], provider_type: str, config: dict) -> dict:
+    with (
+        patch("backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock),
+        patch("backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock),
+    ):
+        response = await client.post(
+            "/api/v1/notifications/",
+            headers=headers,
+            json={"name": provider_type, "provider_type": provider_type, "config": config},
+        )
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _stored(db_session, provider_id: int) -> dict:
+    db_session.expire_all()
+    provider = await db_session.scalar(select(NotificationProvider).where(NotificationProvider.id == provider_id))
+    return json.loads(provider.config)
+
+
+async def _patch(client: AsyncClient, headers: dict[str, str], provider_id: int, body: dict):
+    with (
+        patch(
+            "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
+        ) as live_cleanup,
+        patch("backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock),
+    ):
+        response = await client.patch(f"/api/v1/notifications/{provider_id}", headers=headers, json=body)
+    return response, live_cleanup
+
+
+@pytest.mark.parametrize("provider_type", sorted(CONFIGS))
+async def test_a_reader_gets_placeholders_for_every_secret(async_client: AsyncClient, provider_type):
+    config, secrets = CONFIGS[provider_type]
+    admin = await _admin(async_client)
+    viewer = await _user(async_client, admin, "viewer", ["notifications:read"])
+    created = await _create(async_client, admin, provider_type, config)
+
+    listed = (await async_client.get("/api/v1/notifications/", headers=viewer)).json()
+    single = (await async_client.get(f"/api/v1/notifications/{created['id']}", headers=viewer)).json()
+
+    expected = {key: (M if key in secrets else value) for key, value in config.items()}
+    assert created["config"] == expected
+    assert listed[0]["config"] == expected
+    assert single["config"] == expected
+
+
+async def test_an_unset_secret_is_not_shown_as_set(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    created = await _create(
+        async_client, admin, "ntfy", {"server": "https://ntfy.example", "topic": "t", "auth_token": ""}
+    )
+
+    assert created["config"]["auth_token"] == ""
+
+
+async def test_sending_the_placeholder_back_keeps_the_secret(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "telegram", CONFIGS["telegram"][0])
+
+    response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], "chat_id": "43"}})
+
+    assert response.status_code == 200, response.text
+    assert response.json()["config"] == {"bot_token": M, "chat_id": "43"}
+    assert await _stored(db_session, created["id"]) == {"bot_token": "123:tg", "chat_id": "43"}
+
+
+@pytest.mark.parametrize("provider_type", sorted(CONFIGS))
+async def test_every_type_saved_back_as_shown_stays_as_it_was(async_client: AsyncClient, db_session, provider_type):
+    """The edit dialog sends the config back as it was shown."""
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, provider_type, CONFIGS[provider_type][0])
+
+    response, _ = await _patch(async_client, admin, created["id"], {"config": created["config"]})
+
+    assert response.status_code == 200, response.text
+    assert await _stored(db_session, created["id"]) == CONFIGS[provider_type][0]
+
+
+async def test_the_placeholder_cannot_stand_for_a_secret_never_set(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    created = await _create(
+        async_client, admin, "ntfy", {"server": "https://ntfy.example", "topic": "t", "auth_token": ""}
+    )
+
+    response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], "auth_token": M}})
+
+    assert response.status_code == 400, response.text
+    assert (await _stored(db_session, created["id"]))["auth_token"] == ""
+
+
+async def test_an_empty_secret_clears_it_and_a_new_one_replaces_it(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "ntfy", CONFIGS["ntfy"][0])
+
+    await _patch(async_client, admin, created["id"], {"config": {**created["config"], "auth_token": ""}})
+    assert (await _stored(db_session, created["id"]))["auth_token"] == ""
+
+    await _patch(async_client, admin, created["id"], {"config": {**created["config"], "auth_token": "fresh"}})
+    assert (await _stored(db_session, created["id"]))["auth_token"] == "fresh"
+
+
+@pytest.mark.parametrize(
+    ("provider_type", "change"),
+    [
+        ("email", {"smtp_server": "smtp.elsewhere.example"}),
+        ("email", {"smtp_port": "25"}),
+        ("email", {"security": "none"}),
+        ("ntfy", {"server": "https://ntfy.elsewhere.example"}),
+        ("gotify", {"server": "https://gotify.elsewhere.example"}),
+        ("bark", {"server": "https://bark.elsewhere.example"}),
+        ("webhook", {"webhook_url": "https://elsewhere.example/hook"}),
+    ],
+)
+async def test_a_kept_secret_does_not_follow_a_new_destination(
+    async_client: AsyncClient, db_session, provider_type, change
+):
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, provider_type, CONFIGS[provider_type][0])
+
+    response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], **change}})
+
+    assert response.status_code == 400, response.text
+    assert await _stored(db_session, created["id"]) == CONFIGS[provider_type][0]
+
+
+async def test_a_new_destination_with_its_secret_entered_again_is_saved(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "email", CONFIGS["email"][0])
+
+    response, _ = await _patch(
+        async_client,
+        admin,
+        created["id"],
+        {"config": {**created["config"], "smtp_server": "smtp.new.example", "password": "new-pw"}},
+    )
+
+    assert response.status_code == 200, response.text
+    stored = await _stored(db_session, created["id"])
+    assert stored["smtp_server"] == "smtp.new.example" and stored["password"] == "new-pw"
+
+
+async def test_a_kept_secret_does_not_follow_a_new_provider_type(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "webhook", CONFIGS["webhook"][0])
+
+    response, _ = await _patch(
+        async_client,
+        admin,
+        created["id"],
+        {"provider_type": "discord", "config": {"webhook_url": M}},
+    )
+
+    assert response.status_code == 400, response.text
+    assert await _stored(db_session, created["id"]) == CONFIGS["webhook"][0]
+
+
+async def test_the_placeholder_is_not_a_secret_of_its_own(async_client: AsyncClient):
+    admin = await _admin(async_client)
+
+    response = await async_client.post(
+        "/api/v1/notifications/",
+        headers=admin,
+        json={"name": "tg", "provider_type": "telegram", "config": {"bot_token": M, "chat_id": "42"}},
+    )
+
+    assert response.status_code == 400, response.text
+
+
+async def test_a_notify_provider_kept_as_is_is_not_cleaned_up(async_client: AsyncClient):
+    """Its live activities and widgets are only cleaned up when its device or
+    token changed; the placeholder in the response is not a change."""
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "notify", CONFIGS["notify"][0])
+
+    response, live_cleanup = await _patch(async_client, admin, created["id"], {"config": created["config"]})
+
+    assert response.status_code == 200, response.text
+    live_cleanup.assert_not_awaited()
+
+
+# --- testing an edited provider ---
+
+
+async def _test_config(client: AsyncClient, headers: dict[str, str], body: dict):
+    with patch(
+        "backend.app.api.routes.notifications.notification_service.send_test_notification",
+        new_callable=AsyncMock,
+        return_value=(True, "ok"),
+    ) as send:
+        response = await client.post("/api/v1/notifications/test-config", headers=headers, json=body)
+    return response, send
+
+
+async def test_an_editor_tests_with_the_stored_secret(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    editor = await _user(
+        async_client, admin, "editor", ["notifications:read", "notifications:create", "notifications:update"]
+    )
+    created = await _create(async_client, admin, "telegram", CONFIGS["telegram"][0])
+
+    response, send = await _test_config(
+        async_client,
+        editor,
+        {"provider_type": "telegram", "config": created["config"], "provider_id": created["id"]},
+    )
+
+    assert response.status_code == 200, response.text
+    assert send.await_args.args[1] == {"bot_token": "123:tg", "chat_id": "42"}
+
+
+async def test_someone_who_may_only_create_cannot_test_with_a_stored_secret(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    creator = await _user(async_client, admin, "creator", ["notifications:read", "notifications:create"])
+    created = await _create(async_client, admin, "telegram", CONFIGS["telegram"][0])
+
+    response, send = await _test_config(
+        async_client,
+        creator,
+        {"provider_type": "telegram", "config": created["config"], "provider_id": created["id"]},
+    )
+
+    assert response.status_code == 403, response.text
+    send.assert_not_awaited()
+
+
+async def test_a_test_does_not_send_a_stored_secret_to_a_new_destination(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "gotify", CONFIGS["gotify"][0])
+
+    response, send = await _test_config(
+        async_client,
+        admin,
+        {
+            "provider_type": "gotify",
+            "config": {**created["config"], "server": "https://gotify.elsewhere.example"},
+            "provider_id": created["id"],
+        },
+    )
+
+    assert response.status_code == 400, response.text
+    send.assert_not_awaited()
+
+
+async def test_a_test_without_a_provider_does_not_send_the_placeholder(async_client: AsyncClient):
+    admin = await _admin(async_client)
+
+    response, send = await _test_config(
+        async_client, admin, {"provider_type": "telegram", "config": {"bot_token": M, "chat_id": "42"}}
+    )
+
+    assert response.status_code == 400, response.text
+    send.assert_not_awaited()
+
+
+async def test_a_new_provider_is_tested_as_entered(async_client: AsyncClient):
+    admin = await _admin(async_client)
+
+    response, send = await _test_config(
+        async_client, admin, {"provider_type": "telegram", "config": {"bot_token": "123:tg", "chat_id": "42"}}
+    )
+
+    assert response.status_code == 200, response.text
+    assert send.await_args.args[1] == {"bot_token": "123:tg", "chat_id": "42"}
+
+
+async def test_a_type_change_needs_a_config_of_its_own(async_client: AsyncClient, db_session):
+    """Changing only the type would keep the stored secrets under a type that
+    neither masks nor guards them."""
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "email", CONFIGS["email"][0])
+
+    response, _ = await _patch(async_client, admin, created["id"], {"provider_type": "homeassistant"})
+
+    assert response.status_code == 400, response.text
+    db_session.expire_all()
+    provider = await db_session.get(NotificationProvider, created["id"])
+    assert provider.provider_type == "email"
+
+
+async def test_a_secret_named_for_another_type_is_masked_too(async_client: AsyncClient, db_session):
+    """Leftover keys a provider's type does not use are still not shown, and
+    saving them back as shown keeps them."""
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "homeassistant", {"service": "notify.x", "password": "left-over"})
+
+    assert created["config"] == {"service": "notify.x", "password": M}
+    response, _ = await _patch(async_client, admin, created["id"], {"config": created["config"]})
+    assert response.status_code == 200, response.text
+    assert (await _stored(db_session, created["id"]))["password"] == "left-over"
+
+
+@pytest.mark.parametrize(
+    ("stored", "sent"),
+    [({"smtp_port": 587}, {"smtp_port": "587"}), ({"smtp_port": None}, {"smtp_port": ""})],
+)
+async def test_an_unchanged_destination_in_another_spelling_is_not_a_change(
+    async_client: AsyncClient, db_session, stored, sent
+):
+    """The edit dialog sends every value back as text."""
+    admin = await _admin(async_client)
+    created = await _create(async_client, admin, "email", {**CONFIGS["email"][0], **stored})
+
+    response, _ = await _patch(async_client, admin, created["id"], {"config": {**created["config"], **sent}})
+
+    assert response.status_code == 200, response.text
+    assert (await _stored(db_session, created["id"]))["password"] == "mail-pw"
+
+
+@pytest.mark.parametrize("security", ["", " starttls", "none"])
+async def test_a_kept_smtp_password_is_not_sent_with_weaker_security(async_client: AsyncClient, db_session, security):
+    """With no security stored, the sender uses STARTTLS; anything else it reads
+    as plain SMTP, which would send the stored password unencrypted."""
+    admin = await _admin(async_client)
+    config = {key: value for key, value in CONFIGS["email"][0].items() if key != "security"}
+    created = await _create(async_client, admin, "email", config)
+
+    response, _ = await _patch(
+        async_client, admin, created["id"], {"config": {**created["config"], "security": security}}
+    )
+
+    assert response.status_code == 400, response.text
+    assert await _stored(db_session, created["id"]) == config

+ 6 - 4
backend/tests/integration/test_notify_provider_api.py

@@ -13,6 +13,8 @@ CONFIG = {
     "live_activities": True,
     "lock_screen_widgets": True,
 }
+# Responses show the token as the placeholder (write-only secret).
+SHOWN = {**CONFIG, "token": "********"}
 
 
 async def create_provider(client):
@@ -26,7 +28,7 @@ async def create_provider(client):
 async def test_notify_settings_round_trip_and_push_flags_remain_independent(async_client):
     provider_id = await create_provider(async_client)
     response = await async_client.get(f"/api/v1/notifications/{provider_id}")
-    assert response.json()["config"] == CONFIG
+    assert response.json()["config"] == SHOWN
     assert response.json()["on_print_start"] is False
     assert response.json()["on_print_progress"] is False
 
@@ -80,7 +82,7 @@ async def test_empty_config_update_is_rejected_and_keeps_previous_credentials(as
     response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json={"config": {}})
     assert response.status_code == 422
     saved = await async_client.get(f"/api/v1/notifications/{provider_id}")
-    assert saved.json()["config"] == CONFIG
+    assert saved.json()["config"] == SHOWN
 
 
 async def test_credential_change_queues_old_device_cleanup_with_old_credentials(async_client):
@@ -99,7 +101,7 @@ async def test_credential_change_queues_old_device_cleanup_with_old_credentials(
     assert response.status_code == 200
     cleanup.assert_awaited_once_with(provider_id, CONFIG)
     widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
-    assert response.json()["config"]["token"] == "replacement-token"
+    assert response.json()["config"]["token"] == "********"
 
 
 async def test_disabling_or_changing_push_thread_leaves_cleanup_to_durable_worker(async_client):
@@ -152,7 +154,7 @@ async def test_lock_screen_widgets_can_be_enabled_without_live_activities_or_pus
         },
     )
     assert response.status_code == 200
-    assert response.json()["config"] == config
+    assert response.json()["config"] == {**config, "token": "********"}
     assert response.json()["on_print_start"] is False
     assert response.json()["on_print_complete"] is False
     assert response.json()["on_print_failed"] is False

+ 617 - 0
backend/tests/integration/test_settings_and_plug_secrets.py

@@ -0,0 +1,617 @@
+"""Settings and smart plug secrets are write-only.
+
+Responses show a set secret as a fixed placeholder. Saving the placeholder back
+keeps the stored value, an empty value clears it, and a stored secret only goes
+to the destination it was entered for: changing the destination needs the
+secret entered again. Connection tests fill in a stored secret for those who
+may edit it, for its own destination only.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.settings import Settings
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+M = SECRET_PLACEHOLDER
+
+SETTINGS_SECRETS = {
+    "mqtt_password": "mqtt-pw",
+    "ha_token": "ha-tok",
+    "prometheus_token": "prom-tok",
+    "obico_ml_token": "obico-tok",
+    "virtual_printer_access_code": "12345678",
+}
+
+
+async def _admin(client: AsyncClient) -> dict[str, str]:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "ssadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "ssadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _user(client: AsyncClient, admin: dict[str, str], name: str, permissions: list[str]) -> dict[str, str]:
+    group = await client.post("/api/v1/groups/", headers=admin, json={"name": f"g_{name}", "permissions": permissions})
+    assert group.status_code in (200, 201), group.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": name, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await client.post("/api/v1/auth/login", json={"username": name, "password": "UserPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _stored_setting(db_session, key: str) -> str | None:
+    db_session.expire_all()
+    row = await db_session.scalar(select(Settings).where(Settings.key == key))
+    return row.value if row else None
+
+
+async def _seed_settings(client: AsyncClient, admin: dict[str, str]) -> None:
+    with patch("backend.app.services.mqtt_relay.mqtt_relay.configure", new_callable=AsyncMock):
+        response = await client.put(
+            "/api/v1/settings/",
+            headers=admin,
+            json={
+                **SETTINGS_SECRETS,
+                "mqtt_broker": "broker.example",
+                "mqtt_port": 1883,
+                "mqtt_use_tls": False,
+                "ha_url": "http://ha.example:8123",
+                "obico_ml_url": "http://obico.example:3333",
+            },
+        )
+    assert response.status_code == 200, response.text
+
+
+async def _put_settings(client: AsyncClient, headers: dict[str, str], body: dict):
+    with patch("backend.app.services.mqtt_relay.mqtt_relay.configure", new_callable=AsyncMock):
+        return await client.put("/api/v1/settings/", headers=headers, json=body)
+
+
+# --- settings ---
+
+
+async def test_a_settings_reader_gets_placeholders(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+    viewer = await _user(async_client, admin, "sviewer", ["settings:read"])
+
+    shown = (await async_client.get("/api/v1/settings/", headers=viewer)).json()
+    saved_response = (await _put_settings(async_client, admin, {"date_format": "iso"})).json()
+
+    for key in SETTINGS_SECRETS:
+        assert shown[key] == M, key
+        assert saved_response[key] == M, key
+
+
+async def test_an_unset_settings_secret_is_shown_empty(async_client: AsyncClient):
+    admin = await _admin(async_client)
+
+    shown = (await async_client.get("/api/v1/settings/", headers=admin)).json()
+
+    assert shown["mqtt_password"] == ""
+
+
+async def test_settings_saved_back_as_shown_keep_their_secrets(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+    shown = (await async_client.get("/api/v1/settings/", headers=admin)).json()
+
+    response = await _put_settings(async_client, admin, shown)
+
+    assert response.status_code == 200, response.text
+    for key, value in SETTINGS_SECRETS.items():
+        assert await _stored_setting(db_session, key) == value, key
+
+
+async def test_an_empty_settings_secret_clears_it(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+
+    await _put_settings(async_client, admin, {"prometheus_token": ""})
+
+    assert await _stored_setting(db_session, "prometheus_token") == ""
+
+
+@pytest.mark.parametrize(
+    ("secret", "change"),
+    [
+        ("mqtt_password", {"mqtt_broker": "broker.elsewhere.example"}),
+        ("mqtt_password", {"mqtt_port": 8883}),
+        ("mqtt_password", {"mqtt_use_tls": True}),
+        ("ha_token", {"ha_url": "http://ha.elsewhere.example:8123"}),
+        ("obico_ml_token", {"obico_ml_url": "http://obico.elsewhere.example:3333"}),
+    ],
+)
+@pytest.mark.parametrize("secret_sent", [True, False])
+async def test_a_kept_settings_secret_does_not_follow_a_new_destination(
+    async_client: AsyncClient, db_session, secret, change, secret_sent
+):
+    """Kept means sent as the placeholder, or not sent at all."""
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+
+    body = {**change, **({secret: M} if secret_sent else {})}
+    response = await _put_settings(async_client, admin, body)
+
+    assert response.status_code == 400, response.text
+    field = next(iter(change))
+    shown_before = {"mqtt_broker": "broker.example", "mqtt_port": "1883", "mqtt_use_tls": "false"}
+    assert (
+        await _stored_setting(db_session, field)
+        == {
+            **shown_before,
+            "ha_url": "http://ha.example:8123",
+            "obico_ml_url": "http://obico.example:3333",
+        }[field]
+    )
+
+
+async def test_a_new_settings_destination_with_its_secret_is_saved(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+
+    response = await _put_settings(
+        async_client, admin, {"ha_url": "http://ha.new.example:8123", "ha_token": "new-ha-tok"}
+    )
+
+    assert response.status_code == 200, response.text
+    assert await _stored_setting(db_session, "ha_token") == "new-ha-tok"
+
+
+async def test_the_placeholder_cannot_stand_for_a_settings_secret_never_set(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+
+    response = await _put_settings(async_client, admin, {"prometheus_token": M})
+
+    assert response.status_code == 400, response.text
+    assert not await _stored_setting(db_session, "prometheus_token")
+
+
+async def test_an_api_key_reader_still_gets_secrets_blank(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+    key = await async_client.post("/api/v1/api-keys/", headers=admin, json={"name": "reader", "can_read_status": True})
+    assert key.status_code in (200, 201), key.text
+    api_key = key.json()["key"]
+
+    shown = await async_client.get("/api/v1/settings/", headers={"X-API-Key": api_key})
+
+    assert shown.status_code == 200, shown.text
+    for name in SETTINGS_SECRETS:
+        assert shown.json()[name] in ("", M), name
+
+
+# --- Home Assistant connection test ---
+
+
+async def _ha_test(client: AsyncClient, headers: dict[str, str], body: dict):
+    with patch(
+        "backend.app.api.routes.smart_plugs.homeassistant_service.test_connection",
+        new_callable=AsyncMock,
+        return_value={"success": True, "message": "ok", "error": None},
+    ) as test:
+        response = await client.post("/api/v1/smart-plugs/ha/test-connection", headers=headers, json=body)
+    return response, test
+
+
+async def test_the_ha_test_uses_the_stored_token_for_the_stored_url(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+
+    response, test = await _ha_test(async_client, admin, {"url": "http://ha.example:8123", "token": M})
+
+    assert response.status_code == 200, response.text
+    test.assert_awaited_once_with("http://ha.example:8123", "ha-tok")
+
+
+async def test_the_ha_test_does_not_send_the_stored_token_elsewhere(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+
+    response, test = await _ha_test(async_client, admin, {"url": "http://ha.elsewhere.example:8123", "token": M})
+
+    assert response.status_code == 400, response.text
+    test.assert_not_awaited()
+
+
+async def test_the_ha_test_with_the_stored_token_needs_settings_update(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+    controller = await _user(async_client, admin, "plugctl", ["smart_plugs:control", "settings:read"])
+
+    response, test = await _ha_test(async_client, controller, {"url": "http://ha.example:8123", "token": M})
+
+    assert response.status_code == 403, response.text
+    test.assert_not_awaited()
+
+
+# --- smart plugs ---
+
+
+TASMOTA = {"name": "T", "plug_type": "tasmota", "ip_address": "10.0.0.5", "username": "admin", "password": "tas-pw"}
+REST = {
+    "name": "R",
+    "plug_type": "rest",
+    "rest_on_url": "http://plug.example/on",
+    "rest_off_url": "http://plug.example/off",
+    "rest_headers": '{"Authorization": "Bearer rest-tok"}',
+}
+
+
+async def _create_plug(client: AsyncClient, headers: dict[str, str], body: dict) -> dict:
+    response = await client.post("/api/v1/smart-plugs/", headers=headers, json=body)
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _stored_plug(db_session, plug_id: int) -> SmartPlug:
+    db_session.expire_all()
+    return await db_session.get(SmartPlug, plug_id)
+
+
+async def test_a_plug_reader_gets_placeholders(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    viewer = await _user(async_client, admin, "pviewer", ["smart_plugs:read"])
+    tasmota = await _create_plug(async_client, admin, TASMOTA)
+    rest = await _create_plug(async_client, admin, REST)
+
+    listed = {p["id"]: p for p in (await async_client.get("/api/v1/smart-plugs/", headers=viewer)).json()}
+    single = (await async_client.get(f"/api/v1/smart-plugs/{tasmota['id']}", headers=viewer)).json()
+
+    assert tasmota["password"] == M and rest["rest_headers"] == M
+    assert listed[tasmota["id"]]["password"] == M
+    assert listed[rest["id"]]["rest_headers"] == M
+    assert single["password"] == M
+
+
+async def test_a_plug_saved_back_as_shown_keeps_its_secrets(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    tasmota = await _create_plug(async_client, admin, TASMOTA)
+    rest = await _create_plug(async_client, admin, REST)
+
+    for plug in (tasmota, rest):
+        body = {
+            k: plug[k]
+            for k in (
+                "name",
+                "plug_type",
+                "ip_address",
+                "username",
+                "password",
+                "rest_on_url",
+                "rest_off_url",
+                "rest_headers",
+            )
+        }
+        response = await async_client.patch(f"/api/v1/smart-plugs/{plug['id']}", headers=admin, json=body)
+        assert response.status_code == 200, response.text
+
+    assert (await _stored_plug(db_session, tasmota["id"])).password == "tas-pw"
+    assert (await _stored_plug(db_session, rest["id"])).rest_headers == REST["rest_headers"]
+
+
+@pytest.mark.parametrize(
+    ("plug", "change"),
+    [
+        (TASMOTA, {"ip_address": "10.0.0.66"}),
+        (REST, {"rest_on_url": "http://elsewhere.example/on"}),
+        (REST, {"rest_status_url": "http://elsewhere.example/status"}),
+        (TASMOTA, {"plug_type": "rest", "rest_on_url": "http://elsewhere.example/on"}),
+    ],
+)
+@pytest.mark.parametrize("secret_sent", [True, False])
+async def test_a_kept_plug_secret_does_not_follow_a_new_destination(
+    async_client: AsyncClient, db_session, plug, change, secret_sent
+):
+    admin = await _admin(async_client)
+    created = await _create_plug(async_client, admin, plug)
+    secret = "password" if plug is TASMOTA else "rest_headers"
+
+    body = {**change, **({secret: M} if secret_sent else {})}
+    response = await async_client.patch(f"/api/v1/smart-plugs/{created['id']}", headers=admin, json=body)
+
+    assert response.status_code == 400, response.text
+    stored = await _stored_plug(db_session, created["id"])
+    assert getattr(stored, secret) == plug[secret]
+    assert stored.plug_type == plug["plug_type"]
+
+
+async def test_a_new_plug_destination_with_its_secret_is_saved(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    created = await _create_plug(async_client, admin, TASMOTA)
+
+    response = await async_client.patch(
+        f"/api/v1/smart-plugs/{created['id']}", headers=admin, json={"ip_address": "10.0.0.7", "password": "new-pw"}
+    )
+
+    assert response.status_code == 200, response.text
+    assert (await _stored_plug(db_session, created["id"])).password == "new-pw"
+
+
+async def test_a_new_plug_cannot_take_the_placeholder_as_its_secret(async_client: AsyncClient):
+    admin = await _admin(async_client)
+
+    response = await async_client.post("/api/v1/smart-plugs/", headers=admin, json={**TASMOTA, "password": M})
+
+    assert response.status_code == 400, response.text
+
+
+async def test_the_tasmota_test_uses_the_stored_password_for_its_own_ip(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    created = await _create_plug(async_client, admin, TASMOTA)
+
+    with patch(
+        "backend.app.api.routes.smart_plugs.tasmota_service.test_connection",
+        new_callable=AsyncMock,
+        return_value={"success": True, "state": "ON"},
+    ) as test:
+        same = await async_client.post(
+            "/api/v1/smart-plugs/test-connection",
+            headers=admin,
+            json={"ip_address": "10.0.0.5", "username": "admin", "password": M, "plug_id": created["id"]},
+        )
+        moved = await async_client.post(
+            "/api/v1/smart-plugs/test-connection",
+            headers=admin,
+            json={"ip_address": "10.0.0.66", "username": "admin", "password": M, "plug_id": created["id"]},
+        )
+
+    assert same.status_code == 200, same.text
+    test.assert_awaited_once_with("10.0.0.5", "admin", "tas-pw")
+    assert moved.status_code == 400, moved.text
+
+
+async def test_a_plug_test_with_a_stored_secret_needs_plug_update(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    controller = await _user(async_client, admin, "pctl", ["smart_plugs:read", "smart_plugs:control"])
+    created = await _create_plug(async_client, admin, REST)
+
+    with patch(
+        "backend.app.api.routes.smart_plugs.rest_smart_plug_service.test_connection",
+        new_callable=AsyncMock,
+        return_value={"success": True},
+    ) as test:
+        response = await async_client.post(
+            "/api/v1/smart-plugs/rest/test-connection",
+            headers=controller,
+            json={"url": "http://plug.example/on", "method": "POST", "headers": M, "plug_id": created["id"]},
+        )
+
+    assert response.status_code == 403, response.text
+    test.assert_not_awaited()
+
+
+async def test_the_rest_test_uses_the_stored_headers_for_its_own_urls_only(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    created = await _create_plug(async_client, admin, REST)
+
+    with patch(
+        "backend.app.api.routes.smart_plugs.rest_smart_plug_service.test_connection",
+        new_callable=AsyncMock,
+        return_value={"success": True},
+    ) as test:
+        same = await async_client.post(
+            "/api/v1/smart-plugs/rest/test-connection",
+            headers=admin,
+            json={"url": "http://plug.example/off", "method": "POST", "headers": M, "plug_id": created["id"]},
+        )
+        moved = await async_client.post(
+            "/api/v1/smart-plugs/rest/test-connection",
+            headers=admin,
+            json={"url": "http://elsewhere.example/x", "method": "POST", "headers": M, "plug_id": created["id"]},
+        )
+
+    assert same.status_code == 200, same.text
+    test.assert_awaited_once_with("http://plug.example/off", "POST", REST["rest_headers"])
+    assert moved.status_code == 400, moved.text
+
+
+async def test_a_test_without_a_plug_does_not_send_the_placeholder(async_client: AsyncClient):
+    admin = await _admin(async_client)
+
+    with patch("backend.app.api.routes.smart_plugs.tasmota_service.test_connection", new_callable=AsyncMock) as test:
+        response = await async_client.post(
+            "/api/v1/smart-plugs/test-connection",
+            headers=admin,
+            json={"ip_address": "10.0.0.5", "username": "admin", "password": M},
+        )
+
+    assert response.status_code == 400, response.text
+    test.assert_not_awaited()
+
+
+async def test_a_plug_test_cannot_use_the_secret_of_a_plug_outside_ones_printers(
+    async_client: AsyncClient, printer_factory
+):
+    admin = await _admin(async_client)
+    mine, theirs = await printer_factory(name="Mine"), await printer_factory(name="Theirs")
+    group = await async_client.post(
+        "/api/v1/groups/",
+        headers=admin,
+        json={
+            "name": "g_scoped",
+            "permissions": ["smart_plugs:read", "smart_plugs:control", "smart_plugs:update"],
+            "restrict_printers": True,
+            "printer_ids": [mine.id],
+        },
+    )
+    assert group.status_code in (200, 201), group.text
+    await async_client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": "scoped", "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "scoped", "password": "UserPass1!"})
+    scoped = {"Authorization": f"Bearer {login.json()['access_token']}"}
+    created = await _create_plug(async_client, admin, {**TASMOTA, "printer_id": theirs.id})
+
+    with patch("backend.app.api.routes.smart_plugs.tasmota_service.test_connection", new_callable=AsyncMock) as test:
+        response = await async_client.post(
+            "/api/v1/smart-plugs/test-connection",
+            headers=scoped,
+            json={"ip_address": "10.0.0.5", "username": "admin", "password": M, "plug_id": created["id"]},
+        )
+
+    assert response.status_code == 404, response.text
+    test.assert_not_awaited()
+
+
+# --- Obico connection test ---
+
+
+async def _obico_test(client: AsyncClient, headers: dict[str, str], body: dict):
+    with patch(
+        "backend.app.api.routes.obico.obico_detection_service.test_connection",
+        new_callable=AsyncMock,
+        return_value={"ok": True, "status_code": 200, "body": "", "error": None, "auth_ok": True},
+    ) as test:
+        response = await client.post("/api/v1/obico/test-connection", headers=headers, json=body)
+    return response, test
+
+
+@pytest.mark.parametrize("token", [M, None])
+async def test_the_obico_test_uses_the_stored_token_for_the_stored_url_only(async_client: AsyncClient, token):
+    """The placeholder, or no token at all, means the stored token."""
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+    body = {} if token is None else {"token": token}
+
+    same, test = await _obico_test(async_client, admin, {"url": "http://obico.example:3333", **body})
+    assert same.status_code == 200, same.text
+    test.assert_awaited_once_with("http://obico.example:3333", "obico-tok")
+
+    moved, test = await _obico_test(async_client, admin, {"url": "http://obico.elsewhere.example:3333", **body})
+    assert moved.status_code == 400, moved.text
+    test.assert_not_awaited()
+
+
+async def test_the_obico_test_with_a_typed_token_tests_that_token(async_client: AsyncClient):
+    admin = await _admin(async_client)
+    await _seed_settings(async_client, admin)
+
+    response, test = await _obico_test(
+        async_client, admin, {"url": "http://obico.elsewhere.example:3333", "token": "typed"}
+    )
+
+    assert response.status_code == 200, response.text
+    test.assert_awaited_once_with("http://obico.elsewhere.example:3333", "typed")
+
+
+# --- round 2: env HA token, SMTP, LDAP, email security, REST header logs ---
+
+
+async def test_an_env_ha_token_lets_the_url_change_and_is_never_overwritten(
+    async_client: AsyncClient, db_session, monkeypatch
+):
+    """A token from HA_TOKEN is the deployment's, not a stored secret to re-enter:
+    the URL may change, and the placeholder sent back is not written anywhere."""
+    admin = await _admin(async_client)
+    monkeypatch.setenv("HA_TOKEN", "env-ha-tok")
+    monkeypatch.delenv("HA_URL", raising=False)
+
+    response = await _put_settings(async_client, admin, {"ha_url": "http://ha.new.example:8123", "ha_token": M})
+
+    assert response.status_code == 200, response.text
+    assert await _stored_setting(db_session, "ha_url") == "http://ha.new.example:8123"
+    assert await _stored_setting(db_session, "ha_token") in (None, "")
+
+
+async def test_a_kept_smtp_password_does_not_follow_a_new_host(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    smtp = {
+        "smtp_host": "smtp.example",
+        "smtp_port": 587,
+        "smtp_username": "u",
+        "smtp_password": "smtp-pw",
+        "smtp_security": "starttls",
+        "smtp_auth_enabled": True,
+        "smtp_from_email": "a@example.com",
+    }
+    assert (await async_client.post("/api/v1/auth/smtp", headers=admin, json=smtp)).status_code == 200
+
+    for change in ({"smtp_host": "smtp.elsewhere.example"}, {"smtp_port": 25}, {"smtp_security": "none"}):
+        moved = await async_client.post(
+            "/api/v1/auth/smtp", headers=admin, json={**smtp, "smtp_password": None, **change}
+        )
+        assert moved.status_code == 400, (change, moved.text)
+    assert await _stored_setting(db_session, "smtp_host") == "smtp.example"
+
+    kept = await async_client.post("/api/v1/auth/smtp", headers=admin, json={**smtp, "smtp_password": None})
+    assert kept.status_code == 200, kept.text
+    assert await _stored_setting(db_session, "smtp_password") == "smtp-pw"
+
+    entered = await async_client.post(
+        "/api/v1/auth/smtp", headers=admin, json={**smtp, "smtp_host": "smtp.new.example", "smtp_password": "new-pw"}
+    )
+    assert entered.status_code == 200, entered.text
+    assert await _stored_setting(db_session, "smtp_password") == "new-pw"
+
+
+async def test_a_new_smtp_host_without_authentication_drops_the_stored_password(async_client: AsyncClient, db_session):
+    """Nothing to log in with now; the stored password must not be there for later."""
+    admin = await _admin(async_client)
+    smtp = {
+        "smtp_host": "smtp.example",
+        "smtp_port": 587,
+        "smtp_username": "u",
+        "smtp_password": "smtp-pw",
+        "smtp_security": "starttls",
+        "smtp_auth_enabled": True,
+        "smtp_from_email": "a@example.com",
+    }
+    await async_client.post("/api/v1/auth/smtp", headers=admin, json=smtp)
+
+    response = await async_client.post(
+        "/api/v1/auth/smtp",
+        headers=admin,
+        json={**smtp, "smtp_host": "relay.example", "smtp_password": None, "smtp_auth_enabled": False},
+    )
+
+    assert response.status_code == 200, response.text
+    assert not await _stored_setting(db_session, "smtp_password")
+
+
+async def test_a_kept_ldap_bind_password_does_not_follow_a_new_server(async_client: AsyncClient, db_session):
+    admin = await _admin(async_client)
+    await _put_settings(
+        async_client,
+        admin,
+        {"ldap_server_url": "ldaps://ldap.example", "ldap_security": "ldaps", "ldap_bind_password": "bind-pw"},
+    )
+
+    for change in ({"ldap_server_url": "ldap://ldap.elsewhere.example"}, {"ldap_security": "starttls"}):
+        response = await _put_settings(async_client, admin, change)
+        assert response.status_code == 400, (change, response.text)
+    assert await _stored_setting(db_session, "ldap_server_url") == "ldaps://ldap.example"
+
+    response = await _put_settings(
+        async_client, admin, {"ldap_server_url": "ldaps://ldap.new.example", "ldap_bind_password": "new-bind"}
+    )
+    assert response.status_code == 200, response.text
+    assert await _stored_setting(db_session, "ldap_bind_password") == "new-bind"
+
+
+async def test_malformed_rest_headers_are_not_logged(caplog):
+    from backend.app.services.rest_smart_plug import rest_smart_plug_service
+
+    with caplog.at_level("DEBUG"):
+        assert rest_smart_plug_service._parse_headers('{"Authorization": "Bearer leak-me",}') == {}
+
+    assert "leak-me" not in caplog.text
+    assert "REST headers" in caplog.text

+ 26 - 15
backend/tests/integration/test_settings_api.py

@@ -8,6 +8,8 @@ import os
 import pytest
 from httpx import AsyncClient
 
+from backend.app.api.routes.settings import get_homeassistant_settings, get_setting
+
 
 class TestSettingsAPI:
     """Integration tests for /api/v1/settings/ endpoints."""
@@ -396,7 +398,7 @@ class TestSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_mqtt_settings(self, async_client: AsyncClient):
+    async def test_update_mqtt_settings(self, async_client: AsyncClient, db_session):
         """Verify MQTT settings can be updated."""
         response = await async_client.put(
             "/api/v1/settings/",
@@ -417,7 +419,8 @@ class TestSettingsAPI:
         assert result["mqtt_broker"] == "mqtt.example.com"
         assert result["mqtt_port"] == 8883
         assert result["mqtt_username"] == "testuser"
-        assert result["mqtt_password"] == "testpass"
+        assert result["mqtt_password"] == "********"  # write-only
+        assert await get_setting(db_session, "mqtt_password") == "testpass"
         assert result["mqtt_topic_prefix"] == "myprefix"
         assert result["mqtt_use_tls"] is True
 
@@ -733,7 +736,7 @@ class TestSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient):
+    async def test_ha_settings_with_both_env_vars(self, async_client: AsyncClient, db_session):
         """Verify HA settings are overridden when both env vars are set."""
         # Set environment variables
         os.environ["HA_URL"] = "http://supervisor/core"
@@ -747,7 +750,8 @@ class TestSettingsAPI:
 
             # Verify env var values are used
             assert result["ha_url"] == "http://supervisor/core"
-            assert result["ha_token"] == "test-token-12345"
+            assert result["ha_token"] == "********"  # write-only
+            assert (await get_homeassistant_settings(db_session))["ha_token"] == "test-token-12345"
 
             # Verify metadata fields
             assert result["ha_url_from_env"] is True
@@ -790,7 +794,7 @@ class TestSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient):
+    async def test_ha_settings_with_only_token_env_var(self, async_client: AsyncClient, db_session):
         """Verify partial configuration when only HA_TOKEN is set."""
         # Set only token env var
         os.environ.pop("HA_URL", None)
@@ -803,7 +807,8 @@ class TestSettingsAPI:
             assert response.status_code == 200
 
             # Verify token is from env, URL is from database
-            assert result["ha_token"] == "test-token-12345"
+            assert result["ha_token"] == "********"  # write-only
+            assert (await get_homeassistant_settings(db_session))["ha_token"] == "test-token-12345"
             assert result["ha_url_from_env"] is False
             assert result["ha_token_from_env"] is True
             assert result["ha_env_managed"] is False
@@ -816,7 +821,7 @@ class TestSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient):
+    async def test_ha_settings_env_vars_override_database(self, async_client: AsyncClient, db_session):
         """Verify environment variables take precedence over database values."""
         # First, set database values
         await async_client.put(
@@ -832,7 +837,8 @@ class TestSettingsAPI:
         response = await async_client.get("/api/v1/settings/")
         result = response.json()
         assert result["ha_url"] == "http://database-url:8123"
-        assert result["ha_token"] == "database-token"
+        assert result["ha_token"] == "********"  # write-only
+        assert (await get_homeassistant_settings(db_session))["ha_token"] == "database-token"
 
         # Now set environment variables
         os.environ["HA_URL"] = "http://env-url/core"
@@ -844,7 +850,8 @@ class TestSettingsAPI:
 
             # Verify env vars override database
             assert result["ha_url"] == "http://env-url/core"
-            assert result["ha_token"] == "env-token-xyz"
+            assert result["ha_token"] == "********"  # write-only
+            assert (await get_homeassistant_settings(db_session))["ha_token"] == "env-token-xyz"
             assert result["ha_url_from_env"] is True
             assert result["ha_token_from_env"] is True
             assert result["ha_env_managed"] is True
@@ -858,13 +865,14 @@ class TestSettingsAPI:
         response = await async_client.get("/api/v1/settings/")
         result = response.json()
         assert result["ha_url"] == "http://database-url:8123"
-        assert result["ha_token"] == "database-token"
+        assert result["ha_token"] == "********"  # write-only
+        assert (await get_homeassistant_settings(db_session))["ha_token"] == "database-token"
         assert result["ha_url_from_env"] is False
         assert result["ha_token_from_env"] is False
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient):
+    async def test_ha_settings_database_updates_accepted_but_ignored(self, async_client: AsyncClient, db_session):
         """Verify database updates are accepted but have no effect when env vars are set."""
         # Set environment variables
         os.environ["HA_URL"] = "http://supervisor/core"
@@ -886,7 +894,8 @@ class TestSettingsAPI:
             # But values should still be from env vars
             result = response.json()
             assert result["ha_url"] == "http://supervisor/core"
-            assert result["ha_token"] == "env-token"
+            assert result["ha_token"] == "********"  # write-only
+            assert (await get_homeassistant_settings(db_session))["ha_token"] == "env-token"
             assert result["ha_url_from_env"] is True
             assert result["ha_token_from_env"] is True
 
@@ -917,7 +926,7 @@ class TestSettingsAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient):
+    async def test_ha_settings_can_be_updated_normally_without_env_vars(self, async_client: AsyncClient, db_session):
         """Verify HA settings can be updated normally when env vars are not set."""
         # Ensure no env vars
         os.environ.pop("HA_URL", None)
@@ -937,7 +946,8 @@ class TestSettingsAPI:
         result = response.json()
         assert result["ha_enabled"] is True
         assert result["ha_url"] == "http://192.168.1.100:8123"
-        assert result["ha_token"] == "my-long-lived-token"
+        assert result["ha_token"] == "********"  # write-only
+        assert (await get_homeassistant_settings(db_session))["ha_token"] == "my-long-lived-token"
         assert result["ha_url_from_env"] is False
         assert result["ha_token_from_env"] is False
         assert result["ha_env_managed"] is False
@@ -947,7 +957,8 @@ class TestSettingsAPI:
         result = response.json()
         assert result["ha_enabled"] is True
         assert result["ha_url"] == "http://192.168.1.100:8123"
-        assert result["ha_token"] == "my-long-lived-token"
+        assert result["ha_token"] == "********"  # write-only
+        assert (await get_homeassistant_settings(db_session))["ha_token"] == "my-long-lived-token"
 
 
 class TestOpenInSlicerOverride:

+ 1 - 1
backend/tests/integration/test_smart_plugs_api.py

@@ -935,7 +935,7 @@ class TestSmartPlugsAPI:
 
         assert response.status_code == 200
         result = response.json()
-        assert result["rest_headers"] == '{"Authorization": "Bearer test-token"}'
+        assert result["rest_headers"] == "********"  # write-only
         assert result["rest_status_url"] == "http://ha:8080/api/plug/status"
         assert result["rest_status_path"] == "state"
         assert result["rest_status_on_value"] == "ON"

+ 41 - 0
backend/tests/unit/test_debug_start_logs_no_credentials.py

@@ -0,0 +1,41 @@
+"""Started with DEBUG, the logs still carry no credentials.
+
+httpx and httpcore log every request URL at INFO/DEBUG, and some carry
+credentials (bot tokens, webhook URLs, API keys in the query). The SQL echo and
+aiosqlite log every statement's parameters, among them every secret saved. The
+runtime log-level toggle already kept these quiet; a start with the DEBUG
+environment variable did not.
+"""
+
+import logging
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[3]
+
+
+def test_a_debug_start_logs_no_request_urls_or_sql_parameters(tmp_path):
+    env = {
+        **os.environ,
+        "DEBUG": "true",
+        "DATA_DIR": str(tmp_path),
+        "LOG_DIR": str(tmp_path / "logs"),
+        "DATABASE_URL": f"sqlite+aiosqlite:///{tmp_path / 'b.db'}",
+    }
+    code = (
+        "import logging\n"
+        "import backend.app.main  # noqa: F401\n"
+        "from backend.app.core.database import engine\n"
+        "print(engine.sync_engine.hide_parameters)\n"
+        "print(logging.getLogger('httpx').getEffectiveLevel(), logging.getLogger('httpcore').getEffectiveLevel(),"
+        " logging.getLogger('aiosqlite').getEffectiveLevel())\n"
+    )
+    result = subprocess.run(
+        [sys.executable, "-c", code], cwd=REPO, env=env, capture_output=True, text=True, timeout=120, check=True
+    )
+
+    *_, hidden, levels = result.stdout.strip().splitlines()
+    assert hidden == "True"
+    assert levels.split() == [str(logging.WARNING)] * 3

+ 174 - 0
frontend/src/__tests__/components/AddNotificationModalSecrets.test.tsx

@@ -0,0 +1,174 @@
+/**
+ * Saved provider secrets come back as a placeholder. The edit dialog keeps it
+ * (sent back unchanged it keeps the secret), says so, lets typing replace it,
+ * and tests an edited provider by its id so the backend fills in the secret.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AddNotificationModal } from '../../components/AddNotificationModal';
+import type { NotificationProvider } from '../../api/client';
+
+const PLACEHOLDER = '********';
+
+afterEach(() => {
+  server.resetHandlers();
+});
+
+function provider(overrides: Partial<NotificationProvider>): NotificationProvider {
+  return {
+    id: 7,
+    name: 'Saved',
+    provider_type: 'telegram',
+    enabled: true,
+    config: { bot_token: PLACEHOLDER, chat_id: '42' },
+    on_print_start: false,
+    on_print_complete: true,
+    on_print_failed: true,
+    on_print_stopped: true,
+    on_print_progress: false,
+    on_print_missing_spool_assignment: false,
+    on_printer_offline: false,
+    on_printer_error: false,
+    on_ai_failure_detection: false,
+    on_filament_low: false,
+    on_maintenance_due: false,
+    on_ams_humidity_high: false,
+    on_ams_temperature_high: false,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
+    on_plate_not_empty: true,
+    on_plate_clear_required: false,
+    on_bed_cooled: false,
+    on_first_layer_complete: false,
+    on_queue_job_added: false,
+    on_queue_job_assigned: false,
+    on_queue_job_started: false,
+    on_queue_job_waiting: true,
+    on_queue_job_skipped: true,
+    on_queue_job_failed: true,
+    on_queue_completed: false,
+    on_stock_reorder_alert: false,
+    on_stock_break_alert: false,
+    quiet_hours_enabled: false,
+    quiet_hours_start: null,
+    quiet_hours_end: null,
+    daily_digest_enabled: false,
+    daily_digest_time: null,
+    printer_id: null,
+    last_success: null,
+    last_error: null,
+    last_error_at: null,
+    created_at: '2026-10-13T00:00:00Z',
+    updated_at: '2026-10-13T00:00:00Z',
+    ...overrides,
+  };
+}
+
+describe('AddNotificationModal saved secrets', () => {
+  it('tests an edited provider by its id, with the placeholder as it came', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('*/api/v1/notifications/test-config', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, message: 'ok' });
+      }),
+    );
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={provider({})} onClose={() => undefined} />);
+
+    await user.click(await screen.findByRole('button', { name: /test configuration/i }));
+
+    await waitFor(() => expect(body).not.toBeNull());
+    expect(body).toMatchObject({ provider_id: 7, config: { bot_token: PLACEHOLDER, chat_id: '42' } });
+  });
+
+  it('tests a new provider without an id', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('*/api/v1/notifications/test-config', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, message: 'ok' });
+      }),
+    );
+    const user = userEvent.setup();
+    render(<AddNotificationModal onClose={() => undefined} />);
+
+    // A new provider starts as email: fill its required fields.
+    await user.type(await screen.findByLabelText(/smtp server/i), 'smtp.example.com');
+    await user.type(screen.getByLabelText(/from email/i), 'a@example.com');
+    await user.type(screen.getByLabelText(/to email/i), 'b@example.com');
+    await user.click(screen.getByRole('button', { name: /test configuration/i }));
+
+    await waitFor(() => expect(body).not.toBeNull());
+    expect(body).not.toHaveProperty('provider_id');
+  });
+
+  it('says a saved secret is kept, and not for a value being entered', async () => {
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={provider({})} onClose={() => undefined} />);
+
+    expect(await screen.findByText(/leave it as is to keep it/i)).toBeInTheDocument();
+
+    const token = screen.getByLabelText(/bot token/i);
+    await user.clear(token);
+    await user.type(token, 'new-token');
+
+    expect(screen.queryByText(/leave it as is to keep it/i)).not.toBeInTheDocument();
+  });
+
+  it('replaces the placeholder with what is typed instead of adding to it', async () => {
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={provider({})} onClose={() => undefined} />);
+
+    const token = await screen.findByLabelText(/bot token/i);
+    await user.click(token);
+    await user.keyboard('fresh');
+
+    expect(token).toHaveValue('fresh');
+  });
+
+  it('sends an empty saved value back empty, not as the text null', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('*/api/v1/notifications/test-config', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, message: 'ok' });
+      }),
+    );
+    const user = userEvent.setup();
+    render(
+      <AddNotificationModal
+        provider={provider({ provider_type: 'ntfy', config: { server: null, topic: 't', auth_token: PLACEHOLDER } })}
+        onClose={() => undefined}
+      />,
+    );
+
+    await user.click(await screen.findByRole('button', { name: /test configuration/i }));
+
+    await waitFor(() => expect(body).not.toBeNull());
+    expect((body as unknown as { config: Record<string, unknown> }).config.server).toBe('');
+  });
+
+  it.each([
+    ['discord', { webhook_url: PLACEHOLDER }, /webhook url/i],
+    ['webhook', { webhook_url: PLACEHOLDER, auth_header: '' }, /webhook url/i],
+    ['pushover', { user_key: PLACEHOLDER, app_token: PLACEHOLDER }, /user key/i],
+    ['pushover', { user_key: PLACEHOLDER, app_token: PLACEHOLDER }, /app token/i],
+    ['callmebot', { phone: '+1', apikey: PLACEHOLDER }, /api key/i],
+    ['bark', { device_key: PLACEHOLDER }, /device key/i],
+  ] as const)('hides the %s secret as a password field', async (type, config, label) => {
+    render(
+      <AddNotificationModal
+        provider={provider({ provider_type: type, config: { ...config } })}
+        onClose={() => undefined}
+      />,
+    );
+
+    expect(await screen.findByLabelText(label)).toHaveAttribute('type', 'password');
+  });
+});

+ 93 - 0
frontend/src/__tests__/components/AddSmartPlugModalSecrets.test.tsx

@@ -0,0 +1,93 @@
+/**
+ * A saved plug's password and REST headers come back as a placeholder. The
+ * dialog keeps it, tests a saved plug by its id so the backend fills it in,
+ * and asks for it again when the plug's address changes.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AddSmartPlugModal } from '../../components/AddSmartPlugModal';
+import type { SmartPlug } from '../../api/client';
+
+const M = '********';
+
+afterEach(() => server.resetHandlers());
+
+function plug(overrides: Partial<SmartPlug>): SmartPlug {
+  return {
+    id: 5,
+    name: 'Plug',
+    plug_type: 'tasmota',
+    ip_address: '10.0.0.5',
+    username: 'admin',
+    password: M,
+    enabled: true,
+    printer_id: null,
+    ...overrides,
+  } as unknown as SmartPlug;
+}
+
+describe('AddSmartPlugModal saved secrets', () => {
+  it('tests a saved Tasmota plug by its id', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('*/api/v1/smart-plugs/test-connection', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, state: 'ON', device_name: 'x' });
+      }),
+    );
+    const user = userEvent.setup();
+    render(<AddSmartPlugModal plug={plug({})} onClose={() => undefined} />);
+
+    await user.click(await screen.findByRole('button', { name: /^test$/i }));
+
+    await waitFor(() => expect(body).not.toBeNull());
+    expect(body).toMatchObject({ ip_address: '10.0.0.5', password: M, plug_id: 5 });
+  });
+
+  it('says the saved password is kept, and asks for it again for a new address', async () => {
+    const user = userEvent.setup();
+    render(<AddSmartPlugModal plug={plug({})} onClose={() => undefined} />);
+
+    expect(await screen.findByText(/leave it as is to keep it/i)).toBeInTheDocument();
+
+    const ip = screen.getByDisplayValue('10.0.0.5');
+    await user.clear(ip);
+    await user.type(ip, '10.0.0.66');
+
+    expect(screen.getByText(/the address changed: enter it again/i)).toBeInTheDocument();
+  });
+
+  it('tests a saved REST plug by its id, and asks for the headers again for a new URL', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.post('*/api/v1/smart-plugs/rest/test-connection', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ success: true, error: null });
+      }),
+    );
+    const user = userEvent.setup();
+    const rest = plug({
+      plug_type: 'rest',
+      ip_address: null,
+      password: null,
+      rest_on_url: 'http://plug.example/on',
+      rest_off_url: 'http://plug.example/off',
+      rest_headers: M,
+    } as Partial<SmartPlug>);
+    render(<AddSmartPlugModal plug={rest} onClose={() => undefined} />);
+
+    await user.click(await screen.findByRole('button', { name: /test connection/i }));
+    await waitFor(() => expect(body).not.toBeNull());
+    expect(body).toMatchObject({ url: 'http://plug.example/on', headers: M, plug_id: 5 });
+
+    const on = screen.getByDisplayValue('http://plug.example/on');
+    await user.clear(on);
+    await user.type(on, 'http://elsewhere.example/on');
+    expect(screen.getByText(/the address changed: enter it again/i)).toBeInTheDocument();
+  });
+});

+ 67 - 0
frontend/src/__tests__/components/FailureDetectionSettingsSecrets.test.tsx

@@ -0,0 +1,67 @@
+/**
+ * The saved Obico token comes back as a placeholder. Kept, it is not sent to a
+ * new URL: changing the URL holds the auto-save until the token is entered
+ * again, and the field says so.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
+
+const M = '********';
+
+const settings = {
+  obico_enabled: true,
+  obico_ml_url: 'http://obico.example:3333',
+  bambuddy_internal_url: '',
+  obico_ml_token: M,
+  obico_sensitivity: 'medium',
+  obico_action: 'notify',
+  obico_poll_interval: 10,
+  obico_enabled_printers: '',
+};
+
+describe('FailureDetectionSettings saved token', () => {
+  let saves: Record<string, unknown>[];
+
+  beforeEach(() => {
+    saves = [];
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(settings)),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Record<string, unknown>;
+        saves.push(body);
+        return HttpResponse.json({ ...settings, ...body });
+      }),
+      http.get('/api/v1/obico/status', () =>
+        HttpResponse.json({ is_running: true, last_error: null, per_printer: {}, thresholds: { low: 0.38, high: 0.78 }, history: [], enabled: true, ml_url: '', sensitivity: 'medium', action: 'notify', poll_interval: 10 }),
+      ),
+      http.get('/api/v1/printers', () => HttpResponse.json([])),
+    );
+  });
+
+  it('holds the save and asks for the token again when the URL changes', async () => {
+    const user = userEvent.setup();
+    render(<FailureDetectionSettings />);
+    expect(await screen.findByText(/leave it as is to keep it/i)).toBeInTheDocument();
+
+    const url = screen.getByDisplayValue('http://obico.example:3333');
+    await user.clear(url);
+    await user.type(url, 'http://obico.elsewhere.example:3333');
+
+    expect(await screen.findByText(/the address changed: enter it again/i)).toBeInTheDocument();
+    await new Promise((resolve) => setTimeout(resolve, 1000));
+    expect(saves).toEqual([]);
+
+    const token = screen.getByPlaceholderText('Leave empty if the server runs without ML_API_TOKEN');
+    await user.click(token);
+    await user.keyboard('new-token');
+
+    await waitFor(() => expect(saves.length).toBeGreaterThan(0), { timeout: 3000 });
+    expect(saves.at(-1)).toMatchObject({ obico_ml_url: 'http://obico.elsewhere.example:3333', obico_ml_token: 'new-token' });
+  });
+});

+ 75 - 0
frontend/src/__tests__/components/SmtpLdapPasswordReentry.test.tsx

@@ -0,0 +1,75 @@
+/**
+ * A saved SMTP or LDAP bind password only goes to the server it was entered
+ * for: changing the server asks for it again, before the save is refused.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { EmailSettings } from '../../components/EmailSettings';
+import { LDAPSettings } from '../../components/LDAPSettings';
+
+const AGAIN = /the address changed: enter it again/i;
+
+describe('saved server passwords', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('*/api/v1/auth/smtp', () =>
+        HttpResponse.json({
+          smtp_host: 'smtp.example',
+          smtp_port: 587,
+          smtp_username: 'u',
+          smtp_password: null,
+          smtp_security: 'starttls',
+          smtp_auth_enabled: true,
+          smtp_from_email: 'a@example.com',
+          smtp_from_name: 'BamBuddy',
+        }),
+      ),
+      http.get('*/api/v1/auth/advanced-auth/status', () => HttpResponse.json({ advanced_auth_enabled: false, smtp_configured: true })),
+      http.get('*/api/v1/settings/', () =>
+        HttpResponse.json({
+          ldap_enabled: false,
+          ldap_server_url: 'ldaps://ldap.example:636',
+          ldap_bind_dn: 'cn=svc,dc=example',
+          ldap_bind_password: '',
+          ldap_search_base: 'dc=example',
+          ldap_user_filter: '(uid={username})',
+          ldap_security: 'ldaps',
+          ldap_group_mapping: '',
+          ldap_auto_provision: false,
+          ldap_default_group: '',
+        }),
+      ),
+      http.get('*/api/v1/auth/ldap/status', () => HttpResponse.json({ ldap_enabled: false, ldap_configured: true })),
+      http.get('*/api/v1/groups/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('asks for the SMTP password again when the server changes', async () => {
+    const user = userEvent.setup();
+    render(<EmailSettings />);
+
+    const host = await screen.findByDisplayValue('smtp.example');
+    expect(screen.queryByText(AGAIN)).not.toBeInTheDocument();
+    await user.clear(host);
+    await user.type(host, 'smtp.elsewhere.example');
+
+    expect(screen.getByText(AGAIN)).toBeInTheDocument();
+  });
+
+  it('asks for the LDAP bind password again when the server changes', async () => {
+    const user = userEvent.setup();
+    render(<LDAPSettings />);
+
+    const url = await screen.findByDisplayValue('ldaps://ldap.example:636');
+    expect(screen.queryByText(AGAIN)).not.toBeInTheDocument();
+    await user.clear(url);
+    await user.type(url, 'ldap://ldap.elsewhere.example');
+
+    expect(screen.getByText(AGAIN)).toBeInTheDocument();
+  });
+});

+ 124 - 0
frontend/src/__tests__/pages/SettingsPageSecrets.test.tsx

@@ -0,0 +1,124 @@
+/**
+ * Saved settings secrets come back as a placeholder. Kept, they are not sent
+ * anywhere new: changing the MQTT broker holds the auto-save until the
+ * password is entered again, and the field says so.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { SettingsPage } from '../../pages/SettingsPage';
+import { setAuthToken } from '../../api/client';
+
+const M = '********';
+
+const settings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  time_format: 'system',
+  date_format: 'system',
+  mqtt_enabled: true,
+  mqtt_broker: 'broker.example',
+  mqtt_port: 1883,
+  mqtt_username: 'u',
+  mqtt_password: M,
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  check_updates: false,
+  check_printer_firmware: false,
+};
+
+describe('SettingsPage saved secrets', () => {
+  let saves: Record<string, unknown>[];
+
+  beforeEach(() => {
+    window.history.replaceState({}, '', '/');
+    vi.mocked(localStorage.getItem).mockReset();
+    localStorage.clear();
+    setAuthToken(null);
+    saves = [];
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(settings)),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Record<string, unknown>;
+        saves.push(body);
+        return HttpResponse.json({ ...settings, ...body });
+      }),
+      http.get('/api/v1/printers/', () => HttpResponse.json([])),
+      http.get('/api/v1/smart-plugs/', () => HttpResponse.json([])),
+      http.get('/api/v1/notifications/', () => HttpResponse.json([])),
+      http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
+      http.get('/api/v1/mqtt/status', () => HttpResponse.json({ enabled: true })),
+      http.get('/api/v1/virtual-printer/status', () => HttpResponse.json({ running: false })),
+      http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
+      http.get('/api/v1/external-links/', () => HttpResponse.json([])),
+    );
+  });
+
+  async function openNetwork() {
+    const user = userEvent.setup();
+    render(<SettingsPage />);
+    await screen.findByText('Date Format');
+    await user.click(screen.getByText('Network'));
+    await screen.findByText('MQTT Publishing');
+    // The page suppresses auto-save right after the settings load.
+    await new Promise((resolve) => setTimeout(resolve, 200));
+    return user;
+  }
+
+  it('saves a new Home Assistant URL when the token comes from the environment', async () => {
+    server.use(
+      http.get('/api/v1/settings/', () =>
+        HttpResponse.json({ ...settings, ha_enabled: true, ha_url: 'http://ha.example:8123', ha_token: M, ha_token_from_env: true }),
+      ),
+    );
+    const user = userEvent.setup();
+    render(<SettingsPage />);
+    await screen.findByText('Date Format');
+    await user.click(screen.getByText('Network'));
+    await new Promise((resolve) => setTimeout(resolve, 200));
+
+    const url = await screen.findByDisplayValue('http://ha.example:8123');
+    await user.clear(url);
+    await user.type(url, 'http://ha.new.example:8123');
+
+    await waitFor(() => expect(saves.length).toBeGreaterThan(0), { timeout: 3000 });
+    expect(saves.at(-1)).toMatchObject({ ha_url: 'http://ha.new.example:8123' });
+  });
+
+  it('says the saved password is kept', async () => {
+    await openNetwork();
+
+    expect(await screen.findByText(/leave it as is to keep it/i)).toBeInTheDocument();
+  });
+
+  it('holds the save and asks for the password again when the broker changes', async () => {
+    const user = await openNetwork();
+
+    const broker = screen.getByPlaceholderText('mqtt.example.com or 192.168.1.100');
+    await user.clear(broker);
+    await user.type(broker, 'broker.elsewhere.example');
+
+    expect(await screen.findByText(/the address changed: enter it again/i)).toBeInTheDocument();
+    await new Promise((resolve) => setTimeout(resolve, 1200));
+    expect(saves).toEqual([]);
+
+    const password = screen
+      .getAllByPlaceholderText('Leave empty for anonymous')
+      .find((el) => el.getAttribute('type') === 'password')!;
+    await user.click(password);
+    await user.keyboard('new-pw');
+
+    await waitFor(() => expect(saves.length).toBeGreaterThan(0), { timeout: 3000 });
+    expect(saves.at(-1)).toMatchObject({ mqtt_broker: 'broker.elsewhere.example', mqtt_password: 'new-pw' });
+  });
+});

+ 24 - 0
frontend/src/__tests__/utils/secrets.test.ts

@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest';
+import { SECRET_PLACEHOLDER, secretsToReenter } from '../../utils/secrets';
+
+const DESTINATIONS = { mqtt_password: ['mqtt_broker', 'mqtt_port'], ha_token: ['ha_url'] } as const;
+
+describe('secretsToReenter', () => {
+  const saved = { mqtt_broker: 'b', mqtt_port: 1883, mqtt_password: SECRET_PLACEHOLDER, ha_url: 'u', ha_token: SECRET_PLACEHOLDER };
+
+  it('names a kept secret whose destination changed', () => {
+    expect(secretsToReenter(saved, { ...saved, mqtt_port: 8883 }, DESTINATIONS)).toEqual(['mqtt_password']);
+  });
+
+  it('leaves a secret entered again alone', () => {
+    expect(secretsToReenter(saved, { ...saved, mqtt_broker: 'x', mqtt_password: 'new' }, DESTINATIONS)).toEqual([]);
+  });
+
+  it('leaves a kept secret whose destination did not change alone', () => {
+    expect(secretsToReenter(saved, { ...saved }, DESTINATIONS)).toEqual([]);
+  });
+
+  it('treats a number and its text as the same value', () => {
+    expect(secretsToReenter(saved, { ...saved, mqtt_port: '1883' }, DESTINATIONS)).toEqual([]);
+  });
+});

+ 8 - 4
frontend/src/api/client.ts

@@ -3610,6 +3610,8 @@ export interface NotificationTestRequest {
   provider_type: ProviderType;
   config: Record<string, unknown>;
   attach_photo?: boolean;
+  /** The saved provider being edited: its stored secrets fill in the placeholders. */
+  provider_id?: number;
 }
 
 export interface NotificationTestResponse {
@@ -6419,10 +6421,11 @@ export const api = {
     }),
   getSmartPlugStatus: (id: number) =>
     request<SmartPlugStatus>(`/smart-plugs/${id}/status`),
-  testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) =>
+  // plug_id: the saved plug being edited, whose stored password fills in the placeholder.
+  testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null, plug_id?: number) =>
     request<SmartPlugTestResult>('/smart-plugs/test-connection', {
       method: 'POST',
-      body: JSON.stringify({ ip_address, username, password }),
+      body: JSON.stringify({ ip_address, username, password, plug_id }),
     }),
 
   // Tasmota Discovery (auto-detects network)
@@ -6482,10 +6485,11 @@ export const api = {
     request<{ message: string }>(`/location-ha-sensors/${id}`, { method: 'DELETE' }),
 
   // REST smart plug
-  testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
+  // plug_id: the saved plug being edited, whose stored headers fill in the placeholder.
+  testRESTConnection: (url: string, method: string = 'GET', headers?: string | null, plug_id?: number) =>
     request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {
       method: 'POST',
-      body: JSON.stringify({ url, method, headers }),
+      body: JSON.stringify({ url, method, headers, plug_id }),
     }),
 
   // Print Queue

+ 13 - 8
frontend/src/components/AddNotificationModal.tsx

@@ -7,6 +7,7 @@ import type { NotificationProvider, NotificationProviderCreate, NotificationProv
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { isNotifyPushOnlyTarget, isNotifyPhotoUnsupported } from '../utils/notify';
+import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
 
 interface ConfigField {
   key: string;
@@ -102,7 +103,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       ? Object.fromEntries(
           Object.entries(provider.config)
             .filter(([k]) => k !== 'event_priorities' && k !== 'live_activities' && k !== 'live_activity_metrics' && k !== 'lock_screen_widgets')
-            .map(([k, v]) => [k, String(v)]),
+            .map(([k, v]) => [k, v == null ? '' : String(v)]),
         )
       : {},
   );
@@ -155,7 +156,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
 
   // Test configuration mutation
   const testMutation = useMutation({
-    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config: configForRequest(), attach_photo: attachPhoto && !notifyPhotosUnsupported }),
+    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config: configForRequest(), attach_photo: attachPhoto && !notifyPhotosUnsupported, provider_id: provider?.id }),
     onSuccess: (result) => {
       setTestResult(result);
       setError(null);
@@ -287,7 +288,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       case 'callmebot':
         return [
           { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
-          { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },
+          { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'password', required: true },
         ];
       case 'ntfy':
         return [
@@ -297,8 +298,8 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         ];
       case 'pushover':
         return [
-          { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
-          { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
+          { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'password', required: true },
+          { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'password', required: true },
           { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
           // Emergency priority (2) requires retry/expire — Pushover rejects the
           // message otherwise. Only shown when priority is set to 2.
@@ -386,11 +387,11 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         ];
       case 'discord':
         return [
-          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },
+          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'password', required: true },
         ];
       case 'webhook':
         return [
-          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
+          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'password', required: true },
           { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [
             { value: 'generic', label: 'Generic JSON' },
             { value: 'slack', label: 'Slack / Mattermost' },
@@ -413,7 +414,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
         ];
       case 'bark':
         return [
-          { key: 'device_key', label: 'Device Key', placeholder: 'Your Bark device key', type: 'text', required: true },
+          { key: 'device_key', label: 'Device Key', placeholder: 'Your Bark device key', type: 'password', required: true },
           { key: 'server', label: 'Server URL', placeholder: 'https://api.day.app', type: 'text', required: false },
           { key: 'group', label: 'Group', placeholder: 'Bambuddy', type: 'text', required: false },
           { key: 'sound', label: 'Sound', placeholder: 'minuet', type: 'text', required: false },
@@ -494,10 +495,14 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                 }
                 setTestResult(null);
               }}
+              onFocus={selectPlaceholderOnFocus}
               placeholder={field.placeholder}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             />
           )}
+          {config[field.key] === SECRET_PLACEHOLDER && (
+            <p className="text-xs text-bambu-gray mt-1">{t('common.secretKept')}</p>
+          )}
           {field.help && (
             <p className="text-xs text-bambu-gray mt-1">{field.help}</p>
           )}

+ 24 - 2
frontend/src/components/AddSmartPlugModal.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
+import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
 
 interface AddSmartPlugModalProps {
   plug?: SmartPlug | null;
@@ -56,6 +57,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || '');
   const [restPowerMultiplier, setRestPowerMultiplier] = useState<string>((plug?.rest_power_multiplier ?? 1).toString());
   const [restEnergyUrl, setRestEnergyUrl] = useState(plug?.rest_energy_url || '');
+  // A saved secret does not follow a changed address: it must be entered again.
+  const passwordNeedsReentry = password === SECRET_PLACEHOLDER && ipAddress !== (plug?.ip_address || '');
+  const headersNeedReentry =
+    restHeaders === SECRET_PLACEHOLDER &&
+    (restOnUrl !== (plug?.rest_on_url || '') ||
+      restOffUrl !== (plug?.rest_off_url || '') ||
+      restStatusUrl !== (plug?.rest_status_url || '') ||
+      restPowerUrl !== (plug?.rest_power_url || '') ||
+      restEnergyUrl !== (plug?.rest_energy_url || ''));
   const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');
   const [restEnergyMultiplier, setRestEnergyMultiplier] = useState<string>((plug?.rest_energy_multiplier ?? 1).toString());
   const [restEnergyTotalPath, setRestEnergyTotalPath] = useState(plug?.rest_energy_total_path || '');
@@ -255,7 +265,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
 
   // Test connection mutation
   const testMutation = useMutation({
-    mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
+    mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null, plug?.id),
     onSuccess: (result) => {
       setTestResult(result);
       setError(null);
@@ -1205,10 +1215,16 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                   <textarea
                     value={restHeaders}
                     onChange={(e) => setRestHeaders(e.target.value)}
+                    onFocus={selectPlaceholderOnFocus}
                     placeholder={t('smartPlugs.restHeadersHint')}
                     rows={2}
                     className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none font-mono text-sm"
                   />
+                  {restHeaders === SECRET_PLACEHOLDER && (
+                    <p className={`text-xs mt-1 ${headersNeedReentry ? 'text-yellow-400' : 'text-bambu-gray'}`}>
+                      {t(headersNeedReentry ? 'common.secretReenter' : 'common.secretKept')}
+                    </p>
+                  )}
                 </div>
               </div>
 
@@ -1362,7 +1378,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                       setTestResult(null);
                       try {
                         const url = restOnUrl.trim() || restOffUrl.trim();
-                        const result = await api.testRESTConnection(url, restMethod, restHeaders.trim() || null);
+                        const result = await api.testRESTConnection(url, restMethod, restHeaders.trim() || null, plug?.id);
                         setTestResult({ success: result.success });
                         if (!result.success) {
                           setError(result.error || t('smartPlugs.addSmartPlug.connectionFailed'));
@@ -1479,9 +1495,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                     type="password"
                     value={password}
                     onChange={(e) => setPassword(e.target.value)}
+                    onFocus={selectPlaceholderOnFocus}
                     placeholder="********"
                     className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
+                  {password === SECRET_PLACEHOLDER && (
+                    <p className={`text-xs mt-1 ${passwordNeedsReentry ? 'text-yellow-400' : 'text-bambu-gray'}`}>
+                      {t(passwordNeedsReentry ? 'common.secretReenter' : 'common.secretKept')}
+                    </p>
+                  )}
                 </div>
               </div>
               <p className="text-xs text-bambu-gray -mt-2">

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

@@ -64,6 +64,15 @@ export function EmailSettings() {
     }
   }, [existingSettings]);
 
+  // The saved password only goes to the server it was entered for.
+  const smtpPasswordNeedsReentry =
+    !!existingSettings &&
+    smtpSettings.smtp_auth_enabled &&
+    !smtpSettings.smtp_password &&
+    (smtpSettings.smtp_host !== existingSettings.smtp_host ||
+      smtpSettings.smtp_port !== existingSettings.smtp_port ||
+      smtpSettings.smtp_security !== existingSettings.smtp_security);
+
   const handleSecurityChange = (security: 'starttls' | 'ssl' | 'none') => {
     setSMTPSettings({
       ...smtpSettings,
@@ -296,6 +305,9 @@ export function EmailSettings() {
                     disabled={!smtpSettings.smtp_auth_enabled}
                     className={smtpSettings.smtp_auth_enabled ? inputClasses : disabledInputClasses}
                   />
+                  {smtpPasswordNeedsReentry && (
+                    <p className="text-xs mt-1 text-yellow-400">{t('common.secretReenter')}</p>
+                  )}
                 </div>
               </div>
 

+ 15 - 3
frontend/src/components/FailureDetectionSettings.tsx

@@ -9,6 +9,7 @@ import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { useToast } from '../contexts/ToastContext';
 import { NumberInput } from './NumberInput';
+import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
 
 type TestResult = { ok: boolean; message: string } | null;
 
@@ -137,13 +138,18 @@ export function FailureDetectionSettings() {
     );
   }, [settings, initialized, enabled, mlUrl, bambuddyInternalUrl, internalUrlValid, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
 
+  // A saved token does not follow a changed URL: it must be entered again, and
+  // nothing is saved until it is (the backend would refuse it).
+  const tokenNeedsReentry =
+    mlToken === SECRET_PLACEHOLDER && (settings?.obico_ml_url ?? '') !== mlUrl;
+
   // Auto-save on change (debounced)
   useEffect(() => {
-    if (!hasUnsavedChanges) return;
+    if (!hasUnsavedChanges || tokenNeedsReentry) return;
     const id = setTimeout(() => saveMutation.mutate(), 500);
     return () => clearTimeout(id);
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [hasUnsavedChanges, enabled, mlUrl, bambuddyInternalUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
+  }, [hasUnsavedChanges, tokenNeedsReentry, enabled, mlUrl, bambuddyInternalUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
 
   const handleTest = async () => {
     setTestResult(null);
@@ -154,7 +160,7 @@ export function FailureDetectionSettings() {
       // auto-save debounce — or after a save that failed — those are different
       // values, and "reachable and healthy" for a token the service never
       // received is the reassuring-green-light problem all over again (#2952).
-      if (hasUnsavedChanges) {
+      if (hasUnsavedChanges && !tokenNeedsReentry) {
         await saveMutation.mutateAsync();
       }
       const res = await api.testObicoConnection(mlUrl, mlToken);
@@ -239,11 +245,17 @@ export function FailureDetectionSettings() {
                 type="password"
                 value={mlToken}
                 onChange={(e) => setMlToken(e.target.value)}
+                onFocus={selectPlaceholderOnFocus}
                 autoComplete="off"
                 placeholder={t('failureDetection.mlTokenPlaceholder')}
                 className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
                 disabled={!enabled}
               />
+              {mlToken === SECRET_PLACEHOLDER && (
+                <p className={`text-xs mt-1 ${tokenNeedsReentry ? 'text-yellow-400' : 'text-bambu-gray'}`}>
+                  {t(tokenNeedsReentry ? 'common.secretReenter' : 'common.secretKept')}
+                </p>
+              )}
               <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
               {testResult && (
                 <div

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

@@ -122,6 +122,13 @@ export function LDAPSettings() {
     },
   });
 
+  // A saved bind password only goes to the server it was entered for.
+  const bindPasswordNeedsReentry =
+    !!settings?.ldap_bind_dn &&
+    !form.ldap_bind_password &&
+    (form.ldap_server_url !== (settings.ldap_server_url || '') ||
+      form.ldap_security !== (settings.ldap_security || 'starttls'));
+
   const handleSave = () => {
     if (!form.ldap_server_url) {
       showToast(t('settings.ldap.errors.serverRequired') || 'LDAP server URL is required', 'error');
@@ -314,6 +321,9 @@ export function LDAPSettings() {
                   value={form.ldap_bind_password}
                   onChange={e => setForm({ ...form, ldap_bind_password: e.target.value })}
                 />
+                {bindPasswordNeedsReentry && (
+                  <p className="text-xs mt-1 text-yellow-400">{t('common.secretReenter')}</p>
+                )}
               </div>
             </div>
 

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Gespeichert. Unverändert lassen, um es zu behalten, oder einen neuen Wert eingeben.',
+    secretReenter: 'Die Adresse hat sich geändert: Bitte erneut eingeben. Bis dahin wird nichts gespeichert.',
     plusNMore: '+{{count}} weitere',
     save: 'Speichern',
     saving: 'Speichern...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Saved. Leave it as is to keep it, or enter a new value.',
+    secretReenter: 'The address changed: enter it again. Nothing is saved until you do.',
     plusNMore: '+{{count}} more',
     save: 'Save',
     saving: 'Saving...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Guardado. Déjalo así para conservarlo o introduce un valor nuevo.',
+    secretReenter: 'La dirección ha cambiado: introdúcelo de nuevo. No se guarda nada hasta que lo hagas.',
     plusNMore: '+{{count}} más',
     save: 'Guardar',
     saving: 'Guardando...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Enregistré. Laissez tel quel pour le conserver, ou saisissez une nouvelle valeur.',
+    secretReenter: "L'adresse a changé : saisissez-le à nouveau. Rien n'est enregistré avant cela.",
     plusNMore: '+{{count}} autres',
     save: 'Enregistrer',
     saving: 'Enregistrement...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Salvato. Lascialo così per mantenerlo o inserisci un nuovo valore.',
+    secretReenter: "L'indirizzo è cambiato: inseriscilo di nuovo. Nulla viene salvato finché non lo fai.",
     plusNMore: '+{{count}} altri',
     save: 'Salva',
     saving: 'Salvataggio...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: '保存済みです。そのままにすると維持され、新しい値を入力すると置き換わります。',
+    secretReenter: 'アドレスが変更されました。もう一度入力してください。入力するまで保存されません。',
     plusNMore: '他{{count}}件',
     save: '保存',
     saving: '保存中...',

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

@@ -48,6 +48,8 @@ export default {
     },
   },
   common: {
+    secretKept: '저장되어 있습니다. 그대로 두면 유지되고, 새 값을 입력하면 바뀝니다.',
+    secretReenter: '주소가 변경되었습니다. 다시 입력하세요. 입력할 때까지 저장되지 않습니다.',
     plusNMore: '외 {{count}}개',
     save: '저장',
     saving: '저장 중...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Opgeslagen. Laat het zo om het te behouden, of voer een nieuwe waarde in.',
+    secretReenter: 'Het adres is gewijzigd: voer het opnieuw in. Er wordt niets opgeslagen tot je dat doet.',
     plusNMore: '+{{count}} meer',
     save: 'Opslaan',
     saving: 'Opslaan...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Salvo. Deixe como está para mantê-lo ou digite um novo valor.',
+    secretReenter: 'O endereço mudou: digite-o novamente. Nada é salvo até você fazer isso.',
     plusNMore: '+{{count}} outros',
     save: 'Salvar',
     saving: 'Salvando...',

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

@@ -48,6 +48,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Сохранено. Оставьте как есть, чтобы сохранить, или введите новое значение.',
+    secretReenter: 'Адрес изменился: введите значение снова. До этого ничего не сохраняется.',
     plusNMore: 'ещё {{count}}',
     save: "Сохранить",
     saving: "Сохранение...",

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Sparat. Lämna det som det är för att behålla det, eller ange ett nytt värde.',
+    secretReenter: 'Adressen har ändrats: ange det igen. Inget sparas förrän du gör det.',
     plusNMore: '+{{count}} till',
     save: 'Spara',
     saving: 'Sparar...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Kaydedildi. Korumak için olduğu gibi bırakın veya yeni bir değer girin.',
+    secretReenter: 'Adres değişti: tekrar girin. Siz girene kadar hiçbir şey kaydedilmez.',
     plusNMore: '+{{count}} tane daha',
     save: 'Kaydet',
     saving: 'Kaydediliyor...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: 'Збережено. Залиште як є, щоб зберегти, або введіть нове значення.',
+    secretReenter: 'Адресу змінено: введіть значення ще раз. Доти нічого не зберігається.',
     plusNMore: 'ще {{count}}',
     save: "Зберегти",
     saving: "Збереження...",

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: '已保存。保持不变即可保留,或输入新值。',
+    secretReenter: '地址已更改:请重新输入。在此之前不会保存任何内容。',
     plusNMore: '另 {{count}} 个',
     save: '保存',
     saving: '保存中...',

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

@@ -51,6 +51,8 @@ export default {
     },
   },
   common: {
+    secretKept: '已儲存。保持不變即可保留,或輸入新值。',
+    secretReenter: '位址已變更:請重新輸入。在此之前不會儲存任何內容。',
     plusNMore: '另 {{count}} 個',
     save: '儲存',
     saving: '儲存中...',

+ 38 - 0
frontend/src/pages/SettingsPage.tsx

@@ -17,6 +17,7 @@ import {
   useLocationSensorColorPrefs,
 } from '../utils/locationSensorDefaults';
 import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
+import { SECRET_PLACEHOLDER, secretsToReenter, selectPlaceholderOnFocus } from '../utils/secrets';
 import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
 import type { Printer as PrinterInfo, APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationHASensor, LocationHASensorReading, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
@@ -220,6 +221,28 @@ function externalCameraSummary(printer: PrinterInfo): string {
   return `${type} · ${where}`;
 }
 
+// The settings that decide where each saved secret is sent. A Home Assistant
+// token from the HA_TOKEN environment variable is the deployment's own and has
+// none: the URL may change without it.
+function settingsSecretDestinations(settings: { ha_token_from_env?: boolean }): Record<string, readonly string[]> {
+  return {
+    mqtt_password: ['mqtt_broker', 'mqtt_port', 'mqtt_use_tls'],
+    ...(settings.ha_token_from_env ? {} : { ha_token: ['ha_url'] }),
+  };
+}
+
+/** Under a secret input showing the placeholder: kept, or to be entered again. */
+function SecretHint({ name, value, reenter }: { name: string; value: unknown; reenter: string[] }) {
+  const { t } = useTranslation();
+  if (value !== SECRET_PLACEHOLDER) return null;
+  const again = reenter.includes(name);
+  return (
+    <p className={`text-xs mt-1 ${again ? 'text-yellow-400' : 'text-bambu-gray'}`}>
+      {t(again ? 'common.secretReenter' : 'common.secretKept')}
+    </p>
+  );
+}
+
 export function SettingsPage() {
   const [cameraTokenRevision, setCameraTokenRevision] = useState(0);
   const queryClient = useQueryClient();
@@ -1214,6 +1237,12 @@ export function SettingsPage() {
       return;
     }
 
+    // A saved secret does not follow a changed address: hold the save until it
+    // is entered again (the field says so), instead of failing on every pause.
+    if (secretsToReenter(baseline, localSettings, settingsSecretDestinations(baseline)).length > 0) {
+      return;
+    }
+
     // Check if there are actual changes
     const hasChanges =
       baseline.auto_archive !== localSettings.auto_archive ||
@@ -1452,6 +1481,9 @@ export function SettingsPage() {
     };
   }, [localSettings, settings, updateMutation, authEnabled, hasPermission, showToast, t]);
 
+  const secretsPendingReentry =
+    settings && localSettings ? secretsToReenter(settings, localSettings, settingsSecretDestinations(settings)) : [];
+
   const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
     // Gate at the point of user interaction (not in the debounced-save effect —
     // that runs on every render and would fire the toast repeatedly). One toast
@@ -3260,6 +3292,7 @@ export function SettingsPage() {
                         type="password"
                         value={localSettings.ha_token ?? ''}
                         onChange={(e) => updateSetting('ha_token', e.target.value)}
+                        onFocus={selectPlaceholderOnFocus}
                         placeholder="eyJ0eXAiOiJKV1QiLC..."
                         disabled={localSettings.ha_token_from_env}
                         className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${
@@ -3270,6 +3303,7 @@ export function SettingsPage() {
                         <Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
                       )}
                     </div>
+                    {!localSettings.ha_token_from_env && <SecretHint name="ha_token" value={localSettings.ha_token} reenter={secretsPendingReentry} />}
                     {localSettings.ha_token_from_env ? (
                       <p className="text-xs text-bambu-gray mt-1">
                         {t('settings.tokenFromEnvReadOnly')}
@@ -3425,9 +3459,11 @@ export function SettingsPage() {
                       type="password"
                       value={localSettings.mqtt_password ?? ''}
                       onChange={(e) => updateSetting('mqtt_password', e.target.value)}
+                      onFocus={selectPlaceholderOnFocus}
                       placeholder={t('settings.leaveEmptyForAnonymous')}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     />
+                    <SecretHint name="mqtt_password" value={localSettings.mqtt_password} reenter={secretsPendingReentry} />
                   </div>
 
                   <div>
@@ -3507,9 +3543,11 @@ export function SettingsPage() {
                       type="password"
                       value={localSettings.prometheus_token ?? ''}
                       onChange={(e) => updateSetting('prometheus_token', e.target.value)}
+                      onFocus={selectPlaceholderOnFocus}
                       placeholder={t('settings.leaveEmptyForNoAuth')}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     />
+                    <SecretHint name="prometheus_token" value={localSettings.prometheus_token} reenter={secretsPendingReentry} />
                     <p className="text-xs text-bambu-gray mt-1">
                       {t('settings.bearerTokenHint')}
                     </p>

+ 30 - 0
frontend/src/utils/secrets.ts

@@ -0,0 +1,30 @@
+import type { FocusEvent } from 'react';
+
+/** How the backend shows a saved secret. Sent back unchanged, it keeps the secret. */
+export const SECRET_PLACEHOLDER = '********';
+
+/** onFocus for a secret input: typing replaces the placeholder instead of adding to it. */
+export function selectPlaceholderOnFocus(e: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
+  if (e.target.value === SECRET_PLACEHOLDER) e.target.select();
+}
+
+/**
+ * Secrets still shown as the placeholder whose destination changed. The
+ * backend refuses to send a saved secret to a new destination, so these must
+ * be entered again before saving.
+ */
+export function secretsToReenter(
+  baseline: object,
+  current: object,
+  destinations: Record<string, readonly string[]>,
+): string[] {
+  const before = baseline as Record<string, unknown>;
+  const now = current as Record<string, unknown>;
+  return Object.entries(destinations)
+    .filter(
+      ([secret, fields]) =>
+        now[secret] === SECRET_PLACEHOLDER &&
+        fields.some((field) => String(before[field] ?? '') !== String(now[field] ?? '')),
+    )
+    .map(([secret]) => secret);
+}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/AdditionalSection-DO6h50lP.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/AmsUnitCard-CszVX8f_.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ArchivesPage-RxRtDasL.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/AreaChart-CrzxG8-V.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/AssignToAmsModal-DNkTh3Ev.js


+ 1 - 1
static/assets/Button-DDr6eAFy.js → static/assets/Button-DSQo4CdN.js

@@ -1 +1 @@
-import{C as e}from"./client-DnySrlFv.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{C as e}from"./client-S4xOa3gM.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CamWallPage-7osYva9u.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraDiagnoseModal-QROEc70W.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraPage-Df-6vgjd.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CameraWall-CDYZxLAy.js


+ 1 - 1
static/assets/Card-Bzc64K35.js → static/assets/Card-DzC7lYtY.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-DnySrlFv.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-S4xOa3gM.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CartesianChart-CVzPCcOl.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ColumnConfigModal-nG02CYJ9.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ConfigureAmsSlotModal-B5JpLtqY.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ConnectAuthorizePage-Bwox01ym.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ContextMenu-DkuBVutl.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/CopyButton-CdIISoIf.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ErrorBarContext-DsWJ4g-p.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ExternalLinkPage-CvuJ0aBj.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 1
static/assets/FileManagerPage-DZp-f_ab.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/FileUploadModal-uoGmBXYB.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/FinancePage-DkVDZYxm.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/GCodeViewerPage-BTfuNr4L.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/GcodeToolpathViewer-DkkwF32o.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/GroupEditPage-CcDPkJTr.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ImagePreviewModal-B3Joz9jQ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/InventoryPage-CmQHmM_H.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/LibraryTrashPage-Ba2VXOXO.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/LoginPage-j07E0hxP.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/MaintenancePage-CJufmCCZ.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ModelSourcesPage-pwZY5gF4.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/NotificationsPage-DLwz-OwK.js


+ 1 - 1
static/assets/OverlayFrame-fLmufrTC.js → static/assets/OverlayFrame-DZh7brCf.js

@@ -1 +1 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-DnySrlFv.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-S4xOa3gM.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 1
static/assets/PdfPreviewModal-BRTIRZjy.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PhotoGalleryModal-CwLjwtm6.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PrinterLocationsPage-CywiwQpW.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/PrintersPage-CyweXXCr.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ProfilesPage-n8uaAAkR.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ProjectDetailPage-bFIzw1aE.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ProjectsPage-BqXh0kGk.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/QueryClientProvider-CdgSKbu5.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/QueuePage-D2ytYqW0.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/ReferenceLine-BPolYgdr.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/RichTextEditor-WujXqvAD.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SettingsPage-C0bPKlYG.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SettingsPage-HSTTX5Rp.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SetupPage-whT6X_8y.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SliceModal-CRiQqnOp.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyAmsPage-D9NH6KQX.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-DDpXnuBb.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyDashboard-BrVMWTwD.js


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-DdGMapW3.js


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Ch0V6WKS.js


Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff