Browse Source

Security hardening (maziggy/bambuddy-security #34)

maziggy 1 week ago
parent
commit
8a6faab0d2
64 changed files with 497 additions and 180 deletions
  1. 29 7
      backend/app/api/routes/settings.py
  2. 2 2
      backend/app/api/routes/support.py
  3. 18 3
      backend/app/api/routes/system.py
  4. 38 18
      backend/app/api/routes/virtual_printers.py
  5. 203 0
      backend/tests/integration/test_system_support_vp_scope.py
  6. 3 2
      backend/tests/unit/test_vp_delete_cleanup.py
  7. 23 0
      frontend/src/__tests__/components/SystemHealthPanel.test.tsx
  8. 27 0
      frontend/src/__tests__/pages/SystemInfoPage.test.tsx
  9. 5 3
      frontend/src/components/SystemHealthPanel.tsx
  10. 146 140
      frontend/src/pages/SystemInfoPage.tsx
  11. 0 0
      static/assets/AdditionalSection-sW_PxxIN.js
  12. 0 0
      static/assets/AmsUnitCard-Cw9zVHtx.js
  13. 0 0
      static/assets/ArchivesPage-Bp0C8e4K.js
  14. 0 0
      static/assets/AssignToAmsModal-iKVVKuv2.js
  15. 0 0
      static/assets/CamWallPage-Bb1p8Vjt.js
  16. 0 0
      static/assets/CameraDiagnoseModal-CZx_g4Xj.js
  17. 0 0
      static/assets/CameraPage-zmYHtNE-.js
  18. 0 0
      static/assets/CameraWall-CB0AsSR2.js
  19. 0 0
      static/assets/ColumnConfigModal-BInwK48I.js
  20. 0 0
      static/assets/ConfigureAmsSlotModal-B_10MC2U.js
  21. 0 0
      static/assets/ConnectAuthorizePage-D1Uu7fB7.js
  22. 0 0
      static/assets/ContextMenu-C2v7hxeA.js
  23. 0 0
      static/assets/ExternalLinkPage-BNSQI8XI.js
  24. 0 1
      static/assets/FileManagerPage-DqEtyu3-.js
  25. 0 0
      static/assets/FileUploadModal-DRvFG9g5.js
  26. 0 0
      static/assets/FinancePage-CS9t6JSR.js
  27. 0 0
      static/assets/GCodeViewerPage-BSQFxZyO.js
  28. 0 0
      static/assets/GroupEditPage-n2Mckm3n.js
  29. 0 0
      static/assets/ImagePreviewModal-BJ1pe2ut.js
  30. 0 0
      static/assets/InventoryPage-CAG8kpXl.js
  31. 0 0
      static/assets/LibraryTrashPage-BF2ExGAG.js
  32. 0 0
      static/assets/LoginPage-BbPbJy3p.js
  33. 0 0
      static/assets/MaintenancePage-yWxT10UQ.js
  34. 0 0
      static/assets/ModelSourcesPage-CgLbd3Uw.js
  35. 0 0
      static/assets/NotificationsPage-DS0JFdJp.js
  36. 0 1
      static/assets/PdfPreviewModal-DOj4pvBp.js
  37. 0 0
      static/assets/PhotoGalleryModal-B5-aGwDg.js
  38. 0 0
      static/assets/PrinterLocationsPage-DkRxz2I5.js
  39. 0 0
      static/assets/PrintersPage-DuEcsENu.js
  40. 0 0
      static/assets/ProfilesPage-K8SRSvdU.js
  41. 0 0
      static/assets/ProjectDetailPage-D6twiwzA.js
  42. 0 0
      static/assets/ProjectsPage-PxHY4wPs.js
  43. 0 0
      static/assets/QueuePage-C56S7qsz.js
  44. 0 0
      static/assets/RichTextEditor-B0IS9hpJ.js
  45. 0 0
      static/assets/SettingsPage-Bp0fy7n4.js
  46. 0 0
      static/assets/SetupPage-BJDzAj_J.js
  47. 0 0
      static/assets/SliceModal-pTIP-OMF.js
  48. 0 0
      static/assets/SpoolBuddyAmsPage-DkT5D4Vu.js
  49. 0 0
      static/assets/SpoolBuddyCalibrationPage-DXzvwBP8.js
  50. 0 0
      static/assets/SpoolBuddyDashboard-svgtSA5J.js
  51. 0 0
      static/assets/SpoolBuddyInventoryPage-ceUo-Een.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-CHsQPljt.js
  53. 0 0
      static/assets/SpoolBuddyWriteTagPage-CXxS_FgA.js
  54. 0 0
      static/assets/SpreadsheetPreviewModal-BwfzHf7b.js
  55. 0 0
      static/assets/StatsPage-C0NqNBRA.js
  56. 0 0
      static/assets/StreamOverlayPage-nyhQUwmS.js
  57. 0 0
      static/assets/SystemInfoPage-C1wx-z2r.js
  58. 0 0
      static/assets/SystemInfoPage-DU8B7rUB.js
  59. 0 0
      static/assets/index-CizsGNuz.js
  60. 1 1
      static/assets/inventorySearch-B6iqizR8.js
  61. 0 0
      static/assets/locationSensorDefaults-DZ_VsorV.js
  62. 0 0
      static/assets/pdf-5uv3yAG2.js
  63. 0 0
      static/assets/utils-A7f2hGkb.js
  64. 1 1
      static/index.html

+ 29 - 7
backend/app/api/routes/settings.py

@@ -13,6 +13,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     ScopedCaller,
     caller_is_api_key,
@@ -23,6 +24,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import APP_VERSION, settings as app_settings
 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.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
@@ -1736,10 +1738,18 @@ async def get_virtual_printer_models(
     }
 
 
+def _legacy_vp_status(status: dict, printer_scope: PrinterScope) -> dict:
+    """The first virtual printer's status, without what names its target for a limited caller."""
+    if printer_scope.is_unrestricted:
+        return status
+    return {key: value for key, value in status.items() if key not in ("name", "serial", "target_printer_ip", "proxy")}
+
+
 @router.get("/virtual-printer")
 async def get_virtual_printer_settings(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get virtual printer settings and status."""
     from backend.app.services.virtual_printer import (
@@ -1765,11 +1775,14 @@ async def get_virtual_printer_settings(
         # `print_queue`) come out as `archive` / `queue` for the frontend.
         "mode": normalize_vp_mode(mode) or VP_MODE_ARCHIVE,
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
-        "target_printer_id": int(target_printer_id) if target_printer_id else None,
+        # A target the caller can't see is not named.
+        "target_printer_id": int(target_printer_id)
+        if target_printer_id and printer_scope.allows(int(target_printer_id))
+        else None,
         "remote_interface_ip": remote_interface_ip or "",
         "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
         "archive_name_source": archive_name_source if archive_name_source in ("metadata", "filename") else "metadata",
-        "status": virtual_printer_manager.get_status(),
+        "status": _legacy_vp_status(virtual_printer_manager.get_status(), printer_scope),
     }
 
 
@@ -1785,6 +1798,7 @@ async def update_virtual_printer_settings(
     archive_name_source: str = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update virtual printer settings and restart services if needed.
 
@@ -1821,6 +1835,18 @@ async def update_virtual_printer_settings(
     new_mode = mode if mode is not None else current_mode
     new_model = model if model is not None else current_model
     new_target_id = target_printer_id if target_printer_id is not None else current_target_id
+    # One bridging to a printer the caller can't use is not theirs to change,
+    # and they can't aim it at such a printer.
+    from backend.app.models.virtual_printer import VirtualPrinter as FirstVP
+
+    # The first virtual printer is the one these settings reach.
+    first_vp = (await db.execute(select(FirstVP).order_by(FirstVP.position, FirstVP.id).limit(1))).scalar_one_or_none()
+    if not printer_scope.allows(current_target_id) or (
+        first_vp is not None and not printer_scope.allows(first_vp.target_printer_id)
+    ):
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if target_printer_id is not None and not printer_scope.allows(target_printer_id):
+        return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
     new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
 
@@ -1918,10 +1944,6 @@ async def update_virtual_printer_settings(
 
     # Propagate tailscale_disabled to the first VirtualPrinter row so sync_from_db() picks it up
     if tailscale_disabled is not None:
-        from backend.app.models.virtual_printer import VirtualPrinter as VPModel
-
-        vp_result = await db.execute(select(VPModel).order_by(VPModel.position).limit(1))
-        first_vp = vp_result.scalar_one_or_none()
         if first_vp is not None:
             first_vp.tailscale_disabled = new_ts_disabled
 
@@ -1952,7 +1974,7 @@ async def update_virtual_printer_settings(
             content={"detail": "Failed to configure virtual printer. Check server logs for details."},
         )
 
-    return await get_virtual_printer_settings(db)
+    return await get_virtual_printer_settings(db, printer_scope=printer_scope)
 
 
 # =============================================================================

+ 2 - 2
backend/app/api/routes/support.py

@@ -177,7 +177,7 @@ async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
     search: str | None = Query(None, description="Search in message or logger name"),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Get recent application log entries with optional filtering."""
     entries, total_lines = read_log_entries(limit=limit, level_filter=level, search=search)
@@ -1492,7 +1492,7 @@ async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
 
 @router.get("/bundle")
 async def generate_support_bundle(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Generate a support bundle ZIP file for issue reporting."""
     # Check if debug logging is enabled and collect sensitive values for redaction

+ 18 - 3
backend/app/api/routes/system.py

@@ -13,11 +13,12 @@ from fastapi import APIRouter, Depends
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
 from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.print_log import PrintLogEntry
@@ -403,12 +404,17 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
 async def get_system_info(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get comprehensive system information."""
 
     # Database stats
     archive_count = await db.scalar(select(func.count(PrintArchive.id)))
-    printer_count = await db.scalar(select(func.count(Printer.id)))
+    # Printers are counted and listed as the caller's printer list shows them.
+    printer_count_query = select(func.count(Printer.id))
+    if (printer_clause := printer_scope.where_strict(Printer.id)) is not None:
+        printer_count_query = printer_count_query.where(printer_clause)
+    printer_count = await db.scalar(printer_count_query)
     filament_count = await db.scalar(select(func.count(Filament.id)))
     project_count = await db.scalar(select(func.count(Project.id)))
     smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
@@ -441,6 +447,8 @@ async def get_system_info(
     # Connected printers
     connected_printers = []
     for printer_id, client in printer_manager._clients.items():
+        if not printer_scope.allows(printer_id):
+            continue
         state = client.state
         if state and state.connected:
             # Get printer name and model from database
@@ -596,6 +604,7 @@ async def get_storage_usage(
 async def get_system_health(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Scan the recent application log against the known-issue catalog.
 
@@ -603,7 +612,13 @@ async def get_system_health(
     Sample lines are sanitized before they leave the process.
     """
     sensitive_strings = await collect_sensitive_strings(db)
-    return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+    result = await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+    # A log line can be about any printer; a caller limited to some gets the
+    # findings without them.
+    if not printer_scope.is_unrestricted:
+        for finding in result.findings:
+            finding.sample = ""
+    return result
 
 
 @router.get("/db-pool")

+ 38 - 18
backend/app/api/routes/virtual_printers.py

@@ -8,9 +8,10 @@ from pydantic import BaseModel
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import 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.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.virtual_printer import VPDiagnosticResult
@@ -82,6 +83,14 @@ def _resolve_printer_model(printer_model: str | None) -> str | None:
     return DISPLAY_NAME_TO_MODEL_CODE.get(printer_model)
 
 
+def _target_missing(target_printer_id: int) -> JSONResponse:
+    return JSONResponse(status_code=400, content={"detail": f"Printer with ID {target_printer_id} not found"})
+
+
+def _vp_missing() -> JSONResponse:
+    return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+
 async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
     """Convert VirtualPrinter model to response dict.
 
@@ -129,6 +138,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
 async def list_virtual_printers(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List all virtual printers with status."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -139,6 +149,10 @@ async def list_virtual_printers(
 
     printers = []
     for vp in vps:
+        # One bridging to a printer the caller can't use is left out: its
+        # status names that printer.
+        if not printer_scope.allows(vp.target_printer_id):
+            continue
         instance = virtual_printer_manager.get_instance(vp.id)
         status = instance.get_status() if instance else {"running": False, "pending_files": 0}
         printers.append(await _vp_to_dict(vp, db, status))
@@ -154,6 +168,7 @@ async def create_virtual_printer(
     body: VirtualPrinterCreate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new virtual printer."""
     from backend.app.models.virtual_printer import VP_MODE_VALUES, VirtualPrinter, normalize_vp_mode
@@ -198,10 +213,8 @@ async def create_virtual_printer(
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         target_printer = result.scalar_one_or_none()
-        if not target_printer:
-            return JSONResponse(
-                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
-            )
+        if not target_printer or not printer_scope.allows(target_printer.id):
+            return _target_missing(body.target_printer_id)
 
     # Validate bind_ip uniqueness (against all enabled VPs)
     if body.bind_ip:
@@ -319,6 +332,7 @@ async def diagnose_virtual_printer(
     vp_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Run setup diagnostics for a virtual printer.
 
@@ -331,8 +345,8 @@ async def diagnose_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     instance = virtual_printer_manager.get_instance(vp.id)
     return await run_vp_diagnostic(vp, instance)
@@ -343,6 +357,7 @@ async def get_virtual_printer(
     vp_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a single virtual printer with status."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -350,8 +365,8 @@ async def get_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     instance = virtual_printer_manager.get_instance(vp.id)
     status = instance.get_status() if instance else {"running": False, "pending_files": 0}
@@ -365,6 +380,7 @@ async def update_virtual_printer(
     body: VirtualPrinterUpdate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a virtual printer."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -372,8 +388,9 @@ async def update_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    # One bridging to a printer the caller can't use is not theirs to change.
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     # Redact the access code before logging — model_dump otherwise includes
     # the plaintext value at DEBUG, violating the project no-secrets-in-logs
@@ -419,10 +436,8 @@ async def update_virtual_printer(
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         target_printer = result.scalar_one_or_none()
-        if not target_printer:
-            return JSONResponse(
-                status_code=400, content={"detail": f"Printer with ID {body.target_printer_id} not found"}
-            )
+        if not target_printer or not printer_scope.allows(target_printer.id):
+            return _target_missing(body.target_printer_id)
         vp.target_printer_id = body.target_printer_id
         # Auto-inherit model from target printer in proxy mode (unless user explicitly set model)
         if body.model is None and vp.mode == "proxy" and target_printer.model:
@@ -495,7 +510,11 @@ async def update_virtual_printer(
             )
             return JSONResponse(
                 status_code=400,
-                content={"detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"},
+                content={
+                    "detail": f"Bind IP {vp.bind_ip} is already in use by '{conflict.name}'"
+                    if printer_scope.allows(conflict.target_printer_id)
+                    else f"Bind IP {vp.bind_ip} is already in use"
+                },
             )
         if effective_mode == "proxy":
             if not vp.target_printer_id:
@@ -587,6 +606,7 @@ async def delete_virtual_printer(
     vp_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete a virtual printer."""
     from backend.app.models.virtual_printer import VirtualPrinter
@@ -594,8 +614,8 @@ async def delete_virtual_printer(
 
     result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
     vp = result.scalar_one_or_none()
-    if not vp:
-        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+    if not vp or not printer_scope.allows(vp.target_printer_id):
+        return _vp_missing()
 
     vp_name = vp.name
 

+ 203 - 0
backend/tests/integration/test_system_support_vp_scope.py

@@ -0,0 +1,203 @@
+"""System info, support logs and virtual printers keep to the caller's printers."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.log_health import LogFinding, ScanResult
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.virtual_printer import virtual_printer_manager
+from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth, _group, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+PERMS = ["printers:read", "system:read", "settings:read", "settings:update"]
+
+
+@pytest.fixture(autouse=True)
+def _no_vp_services():
+    with (
+        patch.object(virtual_printer_manager, "sync_from_db", AsyncMock()),
+        patch.object(virtual_printer_manager, "configure", AsyncMock()),
+    ):
+        yield
+
+
+async def _setup(client: AsyncClient, printer_factory, permissions=PERMS):
+    a = await printer_factory(name="Ours", serial_number="00M09A000000111")
+    b = await printer_factory(name="Theirs", serial_number="00M09A000000222")
+    token = await _admin_token(client)
+    perms = await _group(client, token, "perms", permissions=permissions)
+    team = await _group(client, token, "team", printer_ids=[a.id])
+    member = _auth((await _user(client, token, "member", [perms, team]))[0])
+    return a.id, b.id, _auth(token), member
+
+
+async def test_system_info_lists_and_counts_only_the_caller_printers(async_client: AsyncClient, printer_factory):
+    a_id, b_id, admin, member = await _setup(async_client, printer_factory)
+    connected = SimpleNamespace(state=SimpleNamespace(connected=True, state="IDLE"))
+    with patch.dict(printer_manager._clients, {a_id: connected, b_id: connected}):
+        mine = (await async_client.get("/api/v1/system/info", headers=member)).json()["printers"]
+        everyone = (await async_client.get("/api/v1/system/info", headers=admin)).json()["printers"]
+
+    assert [p["name"] for p in mine["connected_list"]] == ["Ours"]
+    assert (mine["total"], mine["connected"]) == (1, 1)
+    assert (everyone["total"], everyone["connected"]) == (2, 2)
+
+
+async def test_health_findings_come_without_log_lines_for_a_limited_caller(async_client: AsyncClient, printer_factory):
+    a_id, b_id, admin, member = await _setup(async_client, printer_factory)
+
+    def scan(**_):
+        finding = LogFinding(
+            signature_id="x",
+            severity="error",
+            category="bug",
+            wiki_anchor="x",
+            count=1,
+            first_seen="t",
+            last_seen="t",
+            sample="a line",
+        )
+        return ScanResult(findings=[finding], scanned_entries=1, log_available=True, summary={"total": 1})
+
+    with patch("backend.app.api.routes.system.scan_logs", scan):
+        mine = (await async_client.get("/api/v1/system/health", headers=member)).json()
+        everyone = (await async_client.get("/api/v1/system/health", headers=admin)).json()
+
+    assert [(f["signature_id"], f["sample"]) for f in mine["findings"]] == [("x", "")]
+    assert everyone["findings"][0]["sample"] == "a line"
+
+
+async def test_logs_and_the_support_bundle_need_settings_update(async_client: AsyncClient, printer_factory):
+    a_id, b_id, admin, member = await _setup(
+        async_client, printer_factory, permissions=["printers:read", "settings:read"]
+    )
+
+    logs = await async_client.get("/api/v1/support/logs", headers=member)
+    bundle = await async_client.get("/api/v1/support/bundle", headers=member)
+
+    assert (logs.status_code, bundle.status_code) == (403, 403)
+
+
+async def test_a_virtual_printer_bridging_to_another_printer_is_not_shown(async_client: AsyncClient, printer_factory):
+    a_id, b_id, admin, member = await _setup(async_client, printer_factory)
+    created = await async_client.post(
+        "/api/v1/virtual-printers", headers=admin, json={"name": "proxy", "mode": "proxy", "target_printer_id": b_id}
+    )
+    vp_id = created.json()["id"]
+    plain = (await async_client.post("/api/v1/virtual-printers", headers=admin, json={"name": "plain"})).json()["id"]
+
+    listed = (await async_client.get("/api/v1/virtual-printers", headers=member)).json()
+    got = await async_client.get(f"/api/v1/virtual-printers/{vp_id}", headers=member)
+    diagnosed = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic", headers=member)
+    missing = await async_client.get("/api/v1/virtual-printers/999999", headers=member)
+
+    entries = listed["printers"] if isinstance(listed, dict) else listed
+    assert [vp["id"] for vp in entries] == [plain]
+    assert (
+        (got.status_code, got.json())
+        == (diagnosed.status_code, diagnosed.json())
+        == (
+            missing.status_code,
+            missing.json(),
+        )
+    )
+    assert missing.status_code == 404
+
+
+async def test_a_member_cannot_point_a_virtual_printer_at_another_printer(async_client: AsyncClient, printer_factory):
+    a_id, b_id, admin, member = await _setup(async_client, printer_factory)
+    theirs = (
+        await async_client.post(
+            "/api/v1/virtual-printers", headers=admin, json={"name": "t", "mode": "proxy", "target_printer_id": b_id}
+        )
+    ).json()["id"]
+    ours = (
+        await async_client.post(
+            "/api/v1/virtual-printers", headers=member, json={"name": "o", "mode": "proxy", "target_printer_id": a_id}
+        )
+    ).json()["id"]
+
+    created = await async_client.post(
+        "/api/v1/virtual-printers", headers=member, json={"name": "x", "mode": "proxy", "target_printer_id": b_id}
+    )
+    missing = await async_client.post(
+        "/api/v1/virtual-printers", headers=member, json={"name": "x", "mode": "proxy", "target_printer_id": 999_999}
+    )
+    retargeted = await async_client.put(
+        f"/api/v1/virtual-printers/{ours}", headers=member, json={"target_printer_id": b_id}
+    )
+    changed = await async_client.put(f"/api/v1/virtual-printers/{theirs}", headers=member, json={"name": "mine"})
+    deleted = await async_client.delete(f"/api/v1/virtual-printers/{theirs}", headers=member)
+    renamed = await async_client.put(f"/api/v1/virtual-printers/{ours}", headers=member, json={"name": "ours2"})
+
+    await async_client.put(
+        f"/api/v1/virtual-printers/{theirs}", headers=admin, json={"bind_ip": "192.0.2.10", "enabled": True}
+    )
+    clashing = await async_client.put(
+        f"/api/v1/virtual-printers/{ours}", headers=member, json={"bind_ip": "192.0.2.10", "enabled": True}
+    )
+    assert clashing.status_code == 400
+    assert "'" not in clashing.json()["detail"]
+    assert created.status_code == missing.status_code == 400
+    assert created.json()["detail"] == f"Printer with ID {b_id} not found"
+    assert retargeted.status_code == 400
+    assert (changed.status_code, deleted.status_code) == (404, 404)
+    assert renamed.status_code == 200
+    kept = (await async_client.get(f"/api/v1/virtual-printers/{theirs}", headers=admin)).json()
+    assert (kept["name"], kept["target_printer_id"]) == ("t", b_id)
+    assert (await async_client.get(f"/api/v1/virtual-printers/{ours}", headers=admin)).json()[
+        "target_printer_id"
+    ] == a_id
+
+
+async def test_the_legacy_virtual_printer_settings_keep_to_the_caller_printers(
+    async_client: AsyncClient, printer_factory
+):
+    a_id, b_id, admin, member = await _setup(async_client, printer_factory)
+    set_up = await async_client.put(
+        f"/api/v1/settings/virtual-printer?mode=proxy&target_printer_id={b_id}", headers=admin
+    )
+    assert set_up.status_code == 200, set_up.text
+
+    shown = await async_client.get("/api/v1/settings/virtual-printer", headers=member)
+    changed = await async_client.put("/api/v1/settings/virtual-printer?mode=archive", headers=member)
+
+    assert shown.json()["target_printer_id"] is None
+    assert changed.status_code == 404
+    kept = (await async_client.get("/api/v1/settings/virtual-printer", headers=admin)).json()
+    assert (kept["mode"], kept["target_printer_id"]) == ("proxy", b_id)
+
+    await async_client.put(f"/api/v1/settings/virtual-printer?target_printer_id={a_id}", headers=admin)
+    aimed = await async_client.put(f"/api/v1/settings/virtual-printer?target_printer_id={b_id}", headers=member)
+    assert aimed.status_code == 400
+
+    # The settings also reach the first virtual printer.
+    await async_client.post(
+        "/api/v1/virtual-printers", headers=admin, json={"name": "first", "mode": "proxy", "target_printer_id": b_id}
+    )
+    toggled = await async_client.put("/api/v1/settings/virtual-printer?tailscale_disabled=false", headers=member)
+    assert toggled.status_code == 404
+
+
+async def test_the_legacy_status_leaves_out_what_names_the_target(async_client: AsyncClient, printer_factory):
+    a_id, b_id, admin, member = await _setup(async_client, printer_factory)
+    status = {
+        "running": True,
+        "name": "N",
+        "serial": "S",
+        "target_printer_ip": "192.0.2.1",
+        "proxy": {"target_host": "192.0.2.1"},
+    }
+
+    with patch.object(virtual_printer_manager, "get_status", return_value=status):
+        mine = (await async_client.get("/api/v1/settings/virtual-printer", headers=member)).json()["status"]
+        everyone = (await async_client.get("/api/v1/settings/virtual-printer", headers=admin)).json()["status"]
+
+    assert mine == {"running": True}
+    assert everyone == status

+ 3 - 2
backend/tests/unit/test_vp_delete_cleanup.py

@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from backend.app.api.routes.virtual_printers import delete_virtual_printer
+from backend.app.core.printer_scope import ALL_PRINTERS
 
 
 @pytest.mark.asyncio
@@ -56,7 +57,7 @@ async def test_delete_vp_marks_orphan_pending_uploads_discarded(tmp_path, db_ses
         "backend.app.services.virtual_printer.virtual_printer_manager",
         fake_manager,
     ):
-        await delete_virtual_printer(vp_id=vp_id, db=db_session, _=None)
+        await delete_virtual_printer(vp_id=vp_id, db=db_session, _=None, printer_scope=ALL_PRINTERS)
 
     statuses = dict(
         (
@@ -113,7 +114,7 @@ async def test_delete_vp_with_no_orphan_uploads_still_succeeds(tmp_path):
         "backend.app.services.virtual_printer.virtual_printer_manager",
         fake_manager,
     ):
-        await delete_virtual_printer(vp_id=vp_id, db=db, _=None)
+        await delete_virtual_printer(vp_id=vp_id, db=db, _=None, printer_scope=ALL_PRINTERS)
 
     fake_manager.remove_instance.assert_awaited_once_with(vp_id)
     # No directory to remove — and we didn't crash trying to.

+ 23 - 0
frontend/src/__tests__/components/SystemHealthPanel.test.tsx

@@ -71,4 +71,27 @@ describe('SystemHealthPanel', () => {
       'https://wiki.bambuddy.cool/reference/troubleshooting/#wrong-access-code',
     );
   });
+
+  it('leaves out the sample line when there is none', () => {
+    renderPanel({
+      ...BASE,
+      log_available: true,
+      findings: [
+        {
+          signature_id: 'ftp-auth-rejected',
+          severity: 'error',
+          category: 'layer8',
+          wiki_anchor: 'wrong-access-code',
+          count: 4,
+          first_seen: '2026-05-22 09:00:00,000',
+          last_seen: '2026-05-22 10:00:00,000',
+          sample: '',
+        },
+      ],
+      summary: { total: 1, layer8: 1, environment: 0, bug: 0 },
+    });
+
+    expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
+    expect(document.querySelector('.font-mono')).toBeNull();
+  });
 });

+ 27 - 0
frontend/src/__tests__/pages/SystemInfoPage.test.tsx

@@ -8,6 +8,20 @@ import { render } from '../utils';
 import { SystemInfoPage } from '../../pages/SystemInfoPage';
 import { api } from '../../api/client';
 
+// Every permission unless a test takes one away.
+const permissions = vi.hoisted(() => ({ denied: [] as string[] }));
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return {
+    ...actual,
+    useAuth: () => ({
+      ...actual.useAuth(),
+      hasPermission: (permission: string) => !permissions.denied.includes(permission),
+    }),
+  };
+});
+
 // Mock the API client
 vi.mock('../../api/client', () => ({
   api: {
@@ -96,6 +110,19 @@ const mockSystemInfo = {
 describe('SystemInfoPage', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    permissions.denied = [];
+  });
+
+  it('shows the support tools only to users who may change settings', async () => {
+    (api.getSystemInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSystemInfo);
+    permissions.denied = ['settings:update'];
+
+    render(<SystemInfoPage />);
+
+    await waitFor(() => {
+      expect(screen.getByText('X1C-01')).toBeInTheDocument();
+    });
+    expect(screen.queryByText("What's in the support bundle?")).not.toBeInTheDocument();
   });
 
   it('renders loading state initially', async () => {

+ 5 - 3
frontend/src/components/SystemHealthPanel.tsx

@@ -51,9 +51,11 @@ function FindingCard({ finding }: { finding: LogFinding }) {
         </div>
       )}
 
-      <div className="text-xs font-mono text-bambu-gray/70 bg-bambu-dark-secondary rounded px-3 py-2 break-all">
-        {finding.sample}
-      </div>
+      {finding.sample && (
+        <div className="text-xs font-mono text-bambu-gray/70 bg-bambu-dark-secondary rounded px-3 py-2 break-all">
+          {finding.sample}
+        </div>
+      )}
 
       <div className="flex items-center justify-between gap-2 flex-wrap">
         <span className="text-xs text-bambu-gray">

+ 146 - 140
frontend/src/pages/SystemInfoPage.tsx

@@ -25,6 +25,7 @@ import {
   HeartPulse,
 } from 'lucide-react';
 import { api, supportApi, type Printer as PrinterModel } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
 import { Card } from '../components/Card';
 import { LogViewer } from '../components/LogViewer';
 import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
@@ -98,6 +99,9 @@ function Section({
 
 export function SystemInfoPage() {
   const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+  // The logs and the bundle need settings:update, as debug logging does.
+  const canUseSupportTools = hasPermission('settings:update');
   const queryClient = useQueryClient();
   const [bundleError, setBundleError] = useState<string | null>(null);
   const [bundleDownloading, setBundleDownloading] = useState(false);
@@ -244,160 +248,162 @@ export function SystemInfoPage() {
       </Section>
 
       {/* Support & Troubleshooting */}
-      <Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
-        <div className="space-y-4">
-          <p className="text-sm text-bambu-gray">
-            {t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
-          </p>
+      {canUseSupportTools && (
+        <Section title={t('support.title', 'Support & Troubleshooting')} icon={Headphones}>
+          <div className="space-y-4">
+            <p className="text-sm text-bambu-gray">
+              {t('support.description', 'Enable debug logging to capture detailed information, then download a support bundle to share when reporting issues.')}
+            </p>
 
-          {/* Debug Logging Toggle */}
-          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
-            <div className="flex items-center gap-3">
-              <div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
-                <Bug className="w-5 h-5" />
-              </div>
-              <div>
-                <p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
-                <p className="text-sm text-bambu-gray">
-                  {debugLoggingState?.enabled
-                    ? t('support.debugLoggingEnabled', 'Capturing detailed logs')
-                    : t('support.debugLoggingDisabled', 'Normal logging level')}
-                  {debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
-                    <span className="text-amber-700 dark:text-amber-400 ml-2">
-                      ({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
-                    </span>
-                  )}
-                </p>
+            {/* Debug Logging Toggle */}
+            <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+              <div className="flex items-center gap-3">
+                <div className={`p-2 rounded-lg ${debugLoggingState?.enabled ? 'bg-amber-500/20 text-amber-500' : 'bg-bambu-dark-tertiary text-bambu-gray'}`}>
+                  <Bug className="w-5 h-5" />
+                </div>
+                <div>
+                  <p className="font-medium text-white">{t('support.debugLogging', 'Debug Logging')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {debugLoggingState?.enabled
+                      ? t('support.debugLoggingEnabled', 'Capturing detailed logs')
+                      : t('support.debugLoggingDisabled', 'Normal logging level')}
+                    {debugLoggingState?.enabled && debugLoggingState.duration_seconds !== null && (
+                      <span className="text-amber-700 dark:text-amber-400 ml-2">
+                        ({Math.floor(debugLoggingState.duration_seconds / 60)}m {debugLoggingState.duration_seconds % 60}s)
+                      </span>
+                    )}
+                  </p>
+                </div>
               </div>
+              <button
+                onClick={handleToggleDebugLogging}
+                disabled={debugToggling}
+                className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
+                  debugLoggingState?.enabled
+                    ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30'
+                    : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
+                } disabled:opacity-50`}
+              >
+                {debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
+                {debugLoggingState?.enabled
+                  ? t('support.disableDebug', 'Disable')
+                  : t('support.enableDebug', 'Enable')}
+              </button>
             </div>
-            <button
-              onClick={handleToggleDebugLogging}
-              disabled={debugToggling}
-              className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 ${
-                debugLoggingState?.enabled
-                  ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-400 dark:hover:bg-amber-500/30'
-                  : 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
-              } disabled:opacity-50`}
-            >
-              {debugToggling && <Loader2 className="w-4 h-4 animate-spin" />}
-              {debugLoggingState?.enabled
-                ? t('support.disableDebug', 'Disable')
-                : t('support.enableDebug', 'Enable')}
-            </button>
-          </div>
 
-          {/* Support Bundle Download */}
-          <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
-            <div className="flex items-center gap-3">
-              <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
-                <Download className="w-5 h-5" />
+            {/* Support Bundle Download */}
+            <div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
+              <div className="flex items-center gap-3">
+                <div className="p-2 rounded-lg bg-bambu-dark-tertiary text-bambu-green">
+                  <Download className="w-5 h-5" />
+                </div>
+                <div>
+                  <p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
+                  </p>
+                </div>
               </div>
-              <div>
-                <p className="font-medium text-white">{t('support.supportBundle', 'Support Bundle')}</p>
-                <p className="text-sm text-bambu-gray">
-                  {t('support.supportBundleDescription', 'Download system info and logs as a ZIP file')}
+              <button
+                onClick={handleDownloadBundle}
+                disabled={bundleDownloading || !debugLoggingState?.enabled}
+                className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
+                title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
+              >
+                {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
+                {bundleDownloading
+                  ? t('support.bundleGenerating', 'Generating...')
+                  : t('common.download', 'Download')}
+              </button>
+            </div>
+
+            {/* Progress indicator — bundle generation now runs connection +
+                virtual-printer diagnostics and the log-health scan before
+                writing the ZIP (#1506 follow-up), so the wait is longer than
+                a pure file-export. List what's running so it's not opaque. */}
+            {bundleDownloading && (
+              <div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
+                <p className="text-sm font-medium text-white flex items-center gap-2">
+                  <Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
+                  {t('support.bundleGenerating', 'Generating...')}
                 </p>
+                <ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
+                  <li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
+                  <li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
+                  <li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
+                  <li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
+                </ul>
               </div>
-            </div>
-            <button
-              onClick={handleDownloadBundle}
-              disabled={bundleDownloading || !debugLoggingState?.enabled}
-              className="px-4 py-2 rounded-lg font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
-              title={!debugLoggingState?.enabled ? t('support.enableDebugFirst', 'Enable debug logging first') : undefined}
-            >
-              {bundleDownloading && <Loader2 className="w-4 h-4 animate-spin" />}
-              {bundleDownloading
-                ? t('support.bundleGenerating', 'Generating...')
-                : t('common.download', 'Download')}
-            </button>
-          </div>
+            )}
 
-          {/* Progress indicator — bundle generation now runs connection +
-              virtual-printer diagnostics and the log-health scan before
-              writing the ZIP (#1506 follow-up), so the wait is longer than
-              a pure file-export. List what's running so it's not opaque. */}
-          {bundleDownloading && (
-            <div className="p-3 bg-bambu-dark-tertiary/40 rounded-lg space-y-1">
-              <p className="text-sm font-medium text-white flex items-center gap-2">
-                <Loader2 className="w-3.5 h-3.5 animate-spin text-bambu-green" />
-                {t('support.bundleGenerating', 'Generating...')}
-              </p>
-              <ul className="text-xs text-bambu-gray list-disc list-inside space-y-0.5 pl-1">
-                <li>{t('support.bundleStepConnection', 'Running printer connectivity checks')}</li>
-                <li>{t('support.bundleStepVirtualPrinters', 'Running virtual-printer setup checks')}</li>
-                <li>{t('support.bundleStepLogScan', 'Scanning recent logs for known issues')}</li>
-                <li>{t('support.bundleStepBuild', 'Building the support bundle ZIP')}</li>
-              </ul>
-            </div>
-          )}
+            {/* Error message */}
+            {bundleError && (
+              <div className="p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 dark:bg-red-500/20 dark:border-red-500/30 dark:text-red-400 text-sm">
+                {bundleError}
+              </div>
+            )}
 
-          {/* Error message */}
-          {bundleError && (
-            <div className="p-3 bg-red-100 border border-red-300 rounded-lg text-red-700 dark:bg-red-500/20 dark:border-red-500/30 dark:text-red-400 text-sm">
-              {bundleError}
-            </div>
-          )}
+            {/* Instructions */}
+            {!debugLoggingState?.enabled && (
+              <div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
+                <p className="text-sm text-bambu-gray">
+                  <span className="text-amber-700 dark:text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
+                  <br />
+                  1. {t('support.step1', 'Enable debug logging')}
+                  <br />
+                  2. {t('support.step2', 'Reproduce the issue')}
+                  <br />
+                  3. {t('support.step3', 'Download the support bundle')}
+                  <br />
+                  4. {t('support.step4', 'Attach the ZIP file to your issue report')}
+                </p>
+              </div>
+            )}
 
-          {/* Instructions */}
-          {!debugLoggingState?.enabled && (
-            <div className="p-4 bg-bambu-dark-tertiary/50 rounded-lg">
-              <p className="text-sm text-bambu-gray">
-                <span className="text-amber-700 dark:text-amber-400 font-medium">{t('support.instructions', 'To report an issue:')}</span>
-                <br />
-                1. {t('support.step1', 'Enable debug logging')}
-                <br />
-                2. {t('support.step2', 'Reproduce the issue')}
-                <br />
-                3. {t('support.step3', 'Download the support bundle')}
-                <br />
-                4. {t('support.step4', 'Attach the ZIP file to your issue report')}
+            {/* Privacy Info */}
+            <div className="p-4 bg-bambu-dark rounded-lg space-y-3">
+              <p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
+                <div>
+                  <p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
+                  <ul className="text-bambu-gray space-y-0.5">
+                    <li>• {t('support.collectItem1', 'App version and debug mode')}</li>
+                    <li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
+                    <li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
+                    <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
+                    <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
+                    <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
+                    <li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
+                    <li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
+                    <li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
+                    <li>• {t('support.collectItem10', 'Python package versions')}</li>
+                    <li>• {t('support.collectItem11', 'Database health checks')}</li>
+                    <li>• {t('support.collectItem12', 'Docker environment details')}</li>
+                  </ul>
+                </div>
+                <div>
+                  <p className="text-red-700 dark:text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
+                  <ul className="text-bambu-gray space-y-0.5">
+                    <li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
+                    <li>• {t('support.notItem2', 'Access codes and passwords')}</li>
+                    <li>• {t('support.notItem3', 'Email addresses')}</li>
+                    <li>• {t('support.notItem4', 'API keys and tokens')}</li>
+                    <li>• {t('support.notItem5', 'Webhook URLs')}</li>
+                    <li>• {t('support.notItem6', 'Your hostname or username')}</li>
+                    <li>• {t('support.notItem7', 'IP addresses')}</li>
+                  </ul>
+                </div>
+              </div>
+              <p className="text-xs text-bambu-gray/70">
+                {t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}
               </p>
             </div>
-          )}
 
-          {/* Privacy Info */}
-          <div className="p-4 bg-bambu-dark rounded-lg space-y-3">
-            <p className="text-sm font-medium text-white">{t('support.privacyTitle', 'What\'s in the support bundle?')}</p>
-            <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
-              <div>
-                <p className="text-bambu-green font-medium mb-1">{t('support.collected', 'Collected:')}</p>
-                <ul className="text-bambu-gray space-y-0.5">
-                  <li>• {t('support.collectItem1', 'App version and debug mode')}</li>
-                  <li>• {t('support.collectItem2', 'OS, architecture, Python version')}</li>
-                  <li>• {t('support.collectItem3', 'Database statistics (counts only)')}</li>
-                  <li>• {t('support.collectItem4', 'Printer models and nozzle counts')}</li>
-                  <li>• {t('support.collectItem5', 'Non-sensitive settings (themes, formats)')}</li>
-                  <li>• {t('support.collectItem6', 'Debug logs (sanitized)')}</li>
-                  <li>• {t('support.collectItem7', 'Printer connectivity and firmware versions')}</li>
-                  <li>• {t('support.collectItem8', 'Integration status (Spoolman, MQTT, HA)')}</li>
-                  <li>• {t('support.collectItem9', 'Network interfaces (subnets only)')}</li>
-                  <li>• {t('support.collectItem10', 'Python package versions')}</li>
-                  <li>• {t('support.collectItem11', 'Database health checks')}</li>
-                  <li>• {t('support.collectItem12', 'Docker environment details')}</li>
-                </ul>
-              </div>
-              <div>
-                <p className="text-red-700 dark:text-red-400 font-medium mb-1">{t('support.notCollected', 'NOT collected:')}</p>
-                <ul className="text-bambu-gray space-y-0.5">
-                  <li>• {t('support.notItem1', 'Printer names and serial numbers')}</li>
-                  <li>• {t('support.notItem2', 'Access codes and passwords')}</li>
-                  <li>• {t('support.notItem3', 'Email addresses')}</li>
-                  <li>• {t('support.notItem4', 'API keys and tokens')}</li>
-                  <li>• {t('support.notItem5', 'Webhook URLs')}</li>
-                  <li>• {t('support.notItem6', 'Your hostname or username')}</li>
-                  <li>• {t('support.notItem7', 'IP addresses')}</li>
-                </ul>
-              </div>
-            </div>
-            <p className="text-xs text-bambu-gray/70">
-              {t('support.privacyNote', 'Email addresses in logs are replaced with [EMAIL], printer names with [PRINTER], serial numbers with [SERIAL], and IP addresses with [IP].')}
-            </p>
+            {/* Log Viewer */}
+            <LogViewer />
           </div>
-
-          {/* Log Viewer */}
-          <LogViewer />
-        </div>
-      </Section>
+        </Section>
+      )}
 
       {/* Connection Diagnostic */}
       <Section title={t('diagnostic.sectionTitle', 'Connection Diagnostic')} icon={Stethoscope}>

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
static/assets/inventorySearch-B-l6AAPT.js → static/assets/inventorySearch-B6iqizR8.js

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

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


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


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


+ 1 - 1
static/index.html

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

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