Ver código fonte

Security hardening (maziggy/bambuddy-security #41)

maziggy 5 dias atrás
pai
commit
473cdf3f0e
97 arquivos alterados com 612 adições e 60 exclusões
  1. 3 0
      backend/app/api/routes/auth.py
  2. 5 0
      backend/app/api/routes/connected_apps.py
  3. 6 2
      backend/app/api/routes/github_backup.py
  4. 3 1
      backend/app/api/routes/local_backup.py
  5. 5 0
      backend/app/api/routes/mfa.py
  6. 35 8
      backend/app/api/routes/settings.py
  7. 20 0
      backend/app/schemas/settings.py
  8. 46 4
      backend/app/services/github_restore.py
  9. 243 0
      backend/tests/integration/test_admin_only_auth_config.py
  10. 38 0
      backend/tests/unit/test_github_backup_settings_secrets.py
  11. 53 0
      frontend/src/__tests__/components/GitHubBackupSettingsPermissions.test.tsx
  12. 65 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  13. 14 4
      frontend/src/components/GitHubBackupSettings.tsx
  14. 1 0
      frontend/src/i18n/locales/de.ts
  15. 1 0
      frontend/src/i18n/locales/en.ts
  16. 1 0
      frontend/src/i18n/locales/es.ts
  17. 1 0
      frontend/src/i18n/locales/fr.ts
  18. 1 0
      frontend/src/i18n/locales/it.ts
  19. 1 0
      frontend/src/i18n/locales/ja.ts
  20. 1 0
      frontend/src/i18n/locales/ko.ts
  21. 1 0
      frontend/src/i18n/locales/nl.ts
  22. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  23. 1 0
      frontend/src/i18n/locales/ru.ts
  24. 1 0
      frontend/src/i18n/locales/sv.ts
  25. 1 0
      frontend/src/i18n/locales/tr.ts
  26. 1 0
      frontend/src/i18n/locales/uk.ts
  27. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  28. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  29. 58 36
      frontend/src/pages/SettingsPage.tsx
  30. 0 0
      static/assets/AdditionalSection-fSMbPuw9.js
  31. 0 0
      static/assets/AmsUnitCard-D95k25gn.js
  32. 0 0
      static/assets/ArchivesPage-ChOfoAft.js
  33. 0 0
      static/assets/AssignToAmsModal-D-HyELjj.js
  34. 0 0
      static/assets/CamWallPage-BhKS7WgK.js
  35. 0 0
      static/assets/CameraDiagnoseModal-CxHb-nft.js
  36. 0 0
      static/assets/CameraPage-Bn6vIwqz.js
  37. 0 0
      static/assets/CameraWall-DXnerYiu.js
  38. 0 0
      static/assets/ColumnConfigModal-HFameg5X.js
  39. 0 0
      static/assets/ConfigureAmsSlotModal-DhoIx4UF.js
  40. 0 0
      static/assets/ConnectAuthorizePage-DTxC60g_.js
  41. 0 0
      static/assets/ContextMenu-iqRtsVvp.js
  42. 0 0
      static/assets/ExternalLinkPage-CZtw6CmJ.js
  43. 0 1
      static/assets/FileManagerPage-CzqeqocU.js
  44. 0 0
      static/assets/FileUploadModal-DMF2Skp3.js
  45. 0 0
      static/assets/FinancePage-BHp7O39Y.js
  46. 0 0
      static/assets/GCodeViewerPage-8zSaunAN.js
  47. 0 0
      static/assets/GroupEditPage-BoBWZwwG.js
  48. 0 0
      static/assets/ImagePreviewModal-BaWJSEwq.js
  49. 0 0
      static/assets/InventoryPage-Co_zCScb.js
  50. 0 0
      static/assets/LibraryTrashPage-DLOayGs5.js
  51. 0 0
      static/assets/LoginPage-0iVwbUWW.js
  52. 0 0
      static/assets/MaintenancePage-Cvg191n3.js
  53. 0 0
      static/assets/ModelSourcesPage-DOAN1wTC.js
  54. 0 0
      static/assets/NotificationsPage-LnDq2u-R.js
  55. 0 1
      static/assets/PdfPreviewModal-ChXITzbX.js
  56. 0 0
      static/assets/PhotoGalleryModal-BNTfiVvo.js
  57. 0 0
      static/assets/PrinterLocationsPage-pVIP3bvT.js
  58. 0 0
      static/assets/PrintersPage-CfJjO0qZ.js
  59. 0 0
      static/assets/ProfilesPage-dHErhNdd.js
  60. 0 0
      static/assets/ProjectDetailPage-Bazq3GV1.js
  61. 0 0
      static/assets/ProjectsPage-Bw4PfMIZ.js
  62. 0 0
      static/assets/QueuePage-8xZGdwoi.js
  63. 0 0
      static/assets/RichTextEditor-CeJHGeBA.js
  64. 0 0
      static/assets/SettingsPage-Bs507TsR.js
  65. 0 0
      static/assets/SettingsPage-Cx0B8bP7.js
  66. 0 0
      static/assets/SetupPage-CMlk7c2v.js
  67. 0 0
      static/assets/SliceModal-ukdaZ67l.js
  68. 0 0
      static/assets/SpoolBuddyAmsPage-aVlmMXEF.js
  69. 0 0
      static/assets/SpoolBuddyCalibrationPage--rvYD3FM.js
  70. 0 0
      static/assets/SpoolBuddyDashboard-BVnc7UcE.js
  71. 0 0
      static/assets/SpoolBuddyInventoryPage-B_cwibVW.js
  72. 1 1
      static/assets/SpoolBuddySettingsPage-uPp_CSNU.js
  73. 0 0
      static/assets/SpoolBuddyWriteTagPage-sNxvQivE.js
  74. 0 0
      static/assets/SpreadsheetPreviewModal-DgUbBTpb.js
  75. 0 0
      static/assets/StatsPage-tPLr-2-Y.js
  76. 0 0
      static/assets/StreamOverlayPage-cr_K9fLF.js
  77. 0 0
      static/assets/SystemInfoPage-D85udD2P.js
  78. 0 0
      static/assets/de-XCWiEtoC.js
  79. 0 0
      static/assets/es-CiHSQ5Sz.js
  80. 0 0
      static/assets/fr-pJmlpPHb.js
  81. 0 0
      static/assets/index-BtnlYHam.js
  82. 1 1
      static/assets/inventorySearch-CQDOs7S9.js
  83. 0 0
      static/assets/it-LoVD1KO1.js
  84. 0 0
      static/assets/ja-CUYVJKn_.js
  85. 0 0
      static/assets/ko-BGnwoMIV.js
  86. 0 0
      static/assets/locationSensorDefaults-BlWAmrha.js
  87. 0 0
      static/assets/nl-Ta01VgQl.js
  88. 0 0
      static/assets/pdf-CIUI3b0l.js
  89. 0 0
      static/assets/pt-BR-9m186HdZ.js
  90. 0 0
      static/assets/ru-DuR5vIFc.js
  91. 0 0
      static/assets/sv-CPcXdzfs.js
  92. 0 0
      static/assets/tr-q8_fOci2.js
  93. 0 0
      static/assets/uk-BzyvovYn.js
  94. 0 0
      static/assets/utils-RO7t3LDD.js
  95. 0 0
      static/assets/zh-CN-KyPS6omi.js
  96. 0 0
      static/assets/zh-TW-wfZ6fT1e.js
  97. 1 1
      static/index.html

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

@@ -849,6 +849,7 @@ async def logout(
 @router.post("/smtp/test", response_model=TestSMTPResponse)
 async def test_smtp_connection(
     test_request: TestSMTPRequest,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -894,6 +895,7 @@ async def get_smtp_config(
 @router.post("/smtp", response_model=dict)
 async def save_smtp_config(
     smtp_settings: SMTPSettings,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1543,6 +1545,7 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
 
 @router.post("/ldap/test")
 async def test_ldap(
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):

+ 5 - 0
backend/app/api/routes/connected_apps.py

@@ -38,6 +38,7 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user,
     get_password_hash,
@@ -131,6 +132,7 @@ async def list_connected_apps(
 async def create_connected_app(
     data: ConnectedAppCreate,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Register an app. The client secret is in this response and nowhere else."""
@@ -159,6 +161,7 @@ async def update_connected_app(
     app_id: int,
     data: ConnectedAppUpdate,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     app = await _get_app_or_404(db, app_id)
@@ -177,6 +180,7 @@ async def update_connected_app(
 async def rotate_connected_app_secret(
     app_id: int,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Replace the client secret. The old one stops working immediately."""
@@ -193,6 +197,7 @@ async def rotate_connected_app_secret(
 async def delete_connected_app(
     app_id: int,
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     app = await _get_app_or_404(db, app_id)

+ 6 - 2
backend/app/api/routes/github_backup.py

@@ -451,7 +451,7 @@ async def list_commits(
 async def preview_restore(
     ref: str = Query(default="HEAD", pattern=REF_PATTERN),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
 ):
     """Report which categories a given backup commit contains."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -460,7 +460,9 @@ async def preview_restore(
     if not config:
         raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
 
-    preview = await github_restore_service.preview(db, config, ref=ref)
+    preview = await github_restore_service.preview(
+        db, config, ref=ref, as_admin=current_user is None or current_user.is_admin
+    )
     return GitHubRestorePreview(**preview)
 
 
@@ -513,6 +515,8 @@ async def restore_backup(
         ref=request.ref,
         categories=request.categories,
         overwrite_existing=request.overwrite_existing,
+        # A non-admin's restore keeps the sign-in and mail settings as they are.
+        as_admin=current_user is None or current_user.is_admin,
     )
     return GitHubRestoreResponse(**restore_result)
 

+ 3 - 1
backend/app/api/routes/local_backup.py

@@ -5,7 +5,7 @@ import logging
 from fastapi import APIRouter, Path
 from fastapi.responses import FileResponse, JSONResponse
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.services.local_backup import local_backup_service
@@ -74,6 +74,7 @@ async def list_backups(
 @router.get("/backups/{filename}/download")
 async def download_backup(
     filename: str = Path(..., description="Backup filename to download"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
     """Download a specific backup file."""
@@ -91,6 +92,7 @@ async def download_backup(
 @router.post("/backups/{filename}/restore")
 async def restore_backup(
     filename: str = Path(..., description="Backup filename to restore"),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
     """Restore from a scheduled backup file on the server."""

+ 5 - 0
backend/app/api/routes/mfa.py

@@ -1376,6 +1376,7 @@ async def list_all_oidc_providers(
 @router.post("/oidc/providers", response_model=OIDCProviderResponse, status_code=status.HTTP_201_CREATED)
 async def create_oidc_provider(
     body: OIDCProviderCreate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
@@ -1476,6 +1477,7 @@ async def _missing_group_names(db: AsyncSession, names: set[str]) -> set[str]:
 async def update_oidc_provider(
     provider_id: int,
     body: OIDCProviderUpdate,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:
@@ -1575,6 +1577,7 @@ async def update_oidc_provider(
 @router.delete("/oidc/providers/{provider_id}")
 async def delete_oidc_provider(
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> dict:
@@ -1637,6 +1640,7 @@ async def get_oidc_provider_icon(
 @router.delete("/oidc/providers/{provider_id}/icon", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_oidc_provider_icon(
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> Response:
@@ -1666,6 +1670,7 @@ async def delete_oidc_provider_icon(
 @router.post("/oidc/providers/{provider_id}/icon/refresh", response_model=OIDCProviderResponse)
 async def refresh_oidc_provider_icon(
     provider_id: int,
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ) -> OIDCProviderResponse:

+ 35 - 8
backend/app/api/routes/settings.py

@@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
     RequestPrinterScope,
+    RequireAdminIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     ScopedCaller,
     caller_is_api_key,
@@ -27,7 +28,7 @@ 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
+from backend.app.schemas.settings import ADMIN_ONLY_SETTING_KEYS, AppSettings, AppSettingsUpdate
 from backend.app.services import camera_light
 from backend.app.services.secret_fields import SETTINGS_SECRETS, SecretConfigError, mask_fields, merge_partial
 
@@ -337,6 +338,20 @@ async def get_settings(
     return await _build_settings_response(db, is_api_key=_is_api_key)
 
 
+def _as_stored_setting(value: object) -> str:
+    """The text the settings writer below stores for ``value``."""
+    if isinstance(value, bool):
+        return "true" if value else "false"
+    if value is None:
+        return "None"
+    return str(value)
+
+
+def _same_setting_value(new: object, old: object) -> bool:
+    """Whether saving ``new`` stores exactly what ``old`` is stored as."""
+    return _as_stored_setting(new) == _as_stored_setting(old)
+
+
 @router.put("/", response_model=AppSettings)
 async def update_settings(
     settings_update: AppSettingsUpdate,
@@ -369,6 +384,21 @@ async def update_settings(
     if null_keys:
         raise HTTPException(status_code=422, detail=f"These settings cannot be null: {', '.join(null_keys)}")
 
+    # Settings that decide who can sign in are admin-only, like the user routes.
+    # Only a real change counts: the settings page sends every field on each
+    # save, so an unchanged value from a non-admin must pass.
+    if current_user is not None and not current_user.is_admin:
+        changed = sorted(
+            key
+            for key in update_data
+            if key in ADMIN_ONLY_SETTING_KEYS and not _same_setting_value(update_data[key], stored.get(key))
+        )
+        if changed:
+            raise HTTPException(
+                status_code=403,
+                detail=f"Only an administrator can change these settings: {', '.join(changed)}",
+            )
+
     # Like an API key's or a provider's printers: a page still open from before
     # a printer delete would save the deleted id, which SQLite gives to the next
     # printer created (the validator has already checked it is a list of ints).
@@ -434,13 +464,7 @@ async def update_settings(
 
     for key, value in update_data.items():
         # Convert value to string for storage
-        if isinstance(value, bool):
-            str_value = "true" if value else "false"
-        elif value is None:
-            str_value = "None"
-        else:
-            str_value = str(value)
-        await set_setting(db, key, str_value)
+        await set_setting(db, key, _as_stored_setting(value))
 
     await db.commit()
     # Expire all objects to ensure fresh reads after commit
@@ -530,6 +554,7 @@ async def update_electricity_price(
 @router.post("/reset", response_model=AppSettings)
 async def reset_settings(
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Reset all settings to defaults."""
@@ -1006,6 +1031,7 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
 @router.get("/backup")
 async def create_backup(
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
     """Create a complete backup (database + all files) as a ZIP download."""
@@ -1442,6 +1468,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path):
 async def restore_backup(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _admin: User | None = RequireAdminIfAuthEnabled(),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
     """Restore from a complete backup ZIP.

+ 20 - 0
backend/app/schemas/settings.py

@@ -1094,3 +1094,23 @@ class AppSettingsUpdate(BaseModel):
         if not isinstance(order, list) or not all(isinstance(item, str) for item in order):
             raise ValueError("sidebar order must be an array of strings")
         return v
+
+
+# Settings that decide who can sign in or where credential material goes: a
+# directory server that hands out groups, local sign-in, the session length,
+# the address password-reset and welcome links point at, the metrics
+# endpoint's token, and the folder full backups (every password hash) are
+# written to. Admin-only to change (routes/settings.update_settings, and a
+# GitHub restore by a non-admin skips them); the settings permissions keep
+# everything else.
+ADMIN_ONLY_SETTING_KEYS = frozenset(
+    {key for key in AppSettingsUpdate.model_fields if key.startswith("ldap_")}
+    | {
+        "local_login_enabled",
+        "session_max_hours",
+        "external_url",
+        "prometheus_enabled",
+        "prometheus_token",
+        "local_backup_path",
+    }
+)

+ 46 - 4
backend/app/services/github_restore.py

@@ -36,6 +36,7 @@ import json
 import logging
 import os
 import re
+from contextvars import ContextVar
 from dataclasses import dataclass, field as dataclasses_field
 from datetime import datetime, timezone
 
@@ -57,7 +58,7 @@ from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.auth import SMTPSettings
 from backend.app.schemas.github_backup import RestoreCategory
-from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
+from backend.app.schemas.settings import ADMIN_ONLY_SETTING_KEYS, AppSettings, AppSettingsUpdate
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.model_providers.manyfold.config import normalize_url
 from backend.app.services.printer_manager import printer_manager
@@ -142,6 +143,18 @@ _PROTECTED_SETTING_KEYS = {
 # the directory being pointed at, so they need no bind credential from us.
 _PROTECTED_SETTING_PREFIXES = ("ldap_",)
 
+# Restoring as a non-admin: the settings only an admin may change through the
+# settings page (ADMIN_ONLY_SETTING_KEYS) and the mail server's, which only an
+# admin may set (routes/auth.update_smtp_settings), are kept as they are here.
+# A restore is otherwise a way around those checks. Set by preview() and
+# run_restore() for the call; an admin's restore is unchanged.
+_restoring_as_admin: ContextVar[bool] = ContextVar("restoring_as_admin", default=True)
+
+
+def _admin_only_for_restore(key: str) -> bool:
+    return key in ADMIN_ONLY_SETTING_KEYS or key in _EXTRA_RESTORABLE_KEYS
+
+
 # Nozzle diameters the backup collector iterates. A path outside this set means
 # the backup was written by a newer version, so accept it rather than dropping
 # data, but keep the list for validation messages.
@@ -655,7 +668,7 @@ class GitHubRestoreService:
             if _is_blocked_setting_key(key):
                 blocked.append(key)
                 continue
-            if _is_protected_setting_key(key):
+            if _is_protected_setting_key(key) or (not _restoring_as_admin.get() and _admin_only_for_restore(key)):
                 protected.append(key)
                 continue
             if _TWO_FACTOR_KEY.match(key):
@@ -807,12 +820,22 @@ class GitHubRestoreService:
             stored=stored,
         )
 
-    async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
+    async def preview(
+        self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD", *, as_admin: bool = True
+    ) -> dict:
         """Report which categories a commit contains, and how much is in each.
 
         Takes a session because the settings count depends on local state — see
         ``_plan_settings``. ``ref`` stays keyword-friendly for callers.
+        ``as_admin`` False counts as a non-admin's restore would write.
         """
+        token = _restoring_as_admin.set(as_admin)
+        try:
+            return await self._preview(db, config, ref)
+        finally:
+            _restoring_as_admin.reset(token)
+
+    async def _preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str) -> dict:
         resolved, error, commit_info = await self._resolve_ref(config, ref)
         if resolved is None:
             return {"success": False, "message": error, "ref": ref, "categories": []}
@@ -1008,8 +1031,27 @@ class GitHubRestoreService:
         ref: str,
         categories: list[RestoreCategory],
         overwrite_existing: bool = False,
+        *,
+        as_admin: bool = True,
+    ) -> dict:
+        """Apply selected categories from one backup commit.
+
+        ``as_admin`` False keeps the admin-only settings as they are here
+        (see ``_restoring_as_admin``).
+        """
+        token = _restoring_as_admin.set(as_admin)
+        try:
+            return await self._run_restore(config_id, ref, categories, overwrite_existing)
+        finally:
+            _restoring_as_admin.reset(token)
+
+    async def _run_restore(
+        self,
+        config_id: int,
+        ref: str,
+        categories: list[RestoreCategory],
+        overwrite_existing: bool,
     ) -> dict:
-        """Apply selected categories from one backup commit."""
         # Import locally to avoid a module-level cycle: the backup service takes
         # the mirror-image lock against us.
         from backend.app.services.github_backup import github_backup_service

+ 243 - 0
backend/tests/integration/test_admin_only_auth_config.py

@@ -0,0 +1,243 @@
+"""Configuration that decides who can sign in is admin-only.
+
+A group can be granted the settings permissions. Those reached sign-in
+itself: a directory or identity provider that hands out the Administrators
+group, the mail server the reset links go through, the address the links
+point at, the session length, a whole-database restore, a reset that also
+cleared the authentication switch, and a backup holding every password hash.
+Like the user and group routes, these are admin-only; the permissions keep
+everything else.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+# pragma: allowlist secret — test fixtures only, not real credentials
+ADMIN_PW = "AdminConf1!"  # noqa: S105
+MANAGER_PW = "Manager1pass!"  # noqa: S105
+
+MANAGER_PERMISSIONS = [
+    "settings:read",
+    "settings:update",
+    "settings:backup",
+    "settings:restore",
+    "github:restore",
+]
+
+
+async def _login(client: AsyncClient, username: str, password: str) -> dict[str, str]:
+    resp = await client.post("/api/v1/auth/login", json={"username": username, "password": password})
+    assert resp.status_code == 200, resp.text
+    return {"Authorization": f"Bearer {resp.json()['access_token']}"}
+
+
+@pytest.fixture
+async def admin(async_client: AsyncClient) -> dict[str, str]:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "confadmin", "admin_password": ADMIN_PW},
+    )
+    return await _login(async_client, "confadmin", ADMIN_PW)
+
+
+@pytest.fixture
+async def manager(async_client: AsyncClient, admin) -> dict[str, str]:
+    """A non-admin holding every settings permission."""
+    group = await async_client.post(
+        "/api/v1/groups", json={"name": "Settings managers", "permissions": MANAGER_PERMISSIONS}, headers=admin
+    )
+    assert group.status_code == 201, group.text
+    user = await async_client.post(
+        "/api/v1/users",
+        json={"username": "confmanager", "password": MANAGER_PW, "group_ids": [group.json()["id"]]},
+        headers=admin,
+    )
+    assert user.status_code == 201, user.text
+    return await _login(async_client, "confmanager", MANAGER_PW)
+
+
+ADMIN_ONLY_ROUTES = [
+    (
+        "POST",
+        "/api/v1/auth/oidc/providers",
+        {"name": "x", "issuer_url": "https://idp.example", "client_id": "c", "client_secret": "s"},
+    ),
+    ("PUT", "/api/v1/auth/oidc/providers/1", {"name": "x"}),
+    ("DELETE", "/api/v1/auth/oidc/providers/1", None),
+    ("DELETE", "/api/v1/auth/oidc/providers/1/icon", None),
+    ("POST", "/api/v1/auth/oidc/providers/1/icon/refresh", None),
+    ("POST", "/api/v1/auth/smtp", {"smtp_host": "mail.example", "smtp_port": 25}),
+    ("POST", "/api/v1/auth/smtp/test", {"test_recipient": "someone@example.com"}),
+    ("POST", "/api/v1/auth/ldap/test", None),
+    ("POST", "/api/v1/settings/reset", None),
+    ("POST", "/api/v1/local-backup/backups/bambuddy-backup.zip/restore", None),
+    ("GET", "/api/v1/settings/backup", None),
+    ("GET", "/api/v1/local-backup/backups/bambuddy-backup.zip/download", None),
+    # A connected app's redirect address decides where an admin's sign-in goes.
+    ("POST", "/api/v1/connect/apps", {"name": "x", "redirect_uri": "https://attacker.example/cb"}),
+    ("PATCH", "/api/v1/connect/apps/1", {"redirect_uri": "https://attacker.example/cb"}),
+    ("POST", "/api/v1/connect/apps/1/rotate-secret", None),
+    ("DELETE", "/api/v1/connect/apps/1", None),
+]
+
+
+@pytest.mark.parametrize(("method", "path", "body"), ADMIN_ONLY_ROUTES)
+async def test_a_settings_manager_is_refused(async_client: AsyncClient, manager, method, path, body):
+    resp = await async_client.request(method, path, json=body, headers=manager)
+    assert resp.status_code == 403, resp.text
+
+
+async def test_a_settings_manager_cannot_restore_a_database(async_client: AsyncClient, manager):
+    resp = await async_client.post(
+        "/api/v1/settings/restore",
+        files={"file": ("backup.zip", b"PK\x05\x06" + b"\x00" * 18, "application/zip")},
+        headers=manager,
+    )
+    assert resp.status_code == 403, resp.text
+
+
+async def test_a_reset_by_a_settings_manager_leaves_authentication_on(async_client: AsyncClient, manager):
+    await async_client.post("/api/v1/settings/reset", headers=manager)
+
+    status = await async_client.get("/api/v1/auth/status")
+    assert status.json()["auth_enabled"] is True
+
+
+@pytest.mark.parametrize(
+    ("key", "value"),
+    [
+        ("ldap_server_url", "ldap://attacker.example"),
+        ("ldap_default_group", "Administrators"),
+        ("ldap_auto_provision", True),
+        ("ldap_enabled", True),
+        ("local_login_enabled", False),
+        ("session_max_hours", 720),
+        ("external_url", "https://attacker.example"),
+        ("prometheus_enabled", True),
+        ("prometheus_token", "x"),
+        # A full backup written into a folder the web server hands out.
+        ("local_backup_path", "/opt/bambuddy/static/img"),
+        ("external_url", None),
+    ],
+)
+async def test_a_settings_manager_cannot_change_sign_in_settings(async_client: AsyncClient, manager, key, value):
+    for method in ("PUT", "PATCH"):
+        resp = await async_client.request(method, "/api/v1/settings/", json={key: value}, headers=manager)
+        assert resp.status_code == 403, (method, resp.text)
+
+
+async def test_a_settings_manager_still_saves_the_settings_page(async_client: AsyncClient, admin, manager):
+    """The page sends every field on each save; unchanged sign-in fields pass."""
+    current = (await async_client.get("/api/v1/settings/", headers=manager)).json()
+    payload = {k: current[k] for k in ("local_login_enabled", "session_max_hours", "external_url", "ldap_enabled")}
+    payload["date_format"] = "eu" if current.get("date_format") != "eu" else "us"
+
+    resp = await async_client.put("/api/v1/settings/", json=payload, headers=manager)
+
+    assert resp.status_code == 200, resp.text
+    assert resp.json()["date_format"] == payload["date_format"]
+
+
+async def test_a_refused_save_changes_nothing(async_client: AsyncClient, admin, manager):
+    before = (await async_client.get("/api/v1/settings/", headers=admin)).json()
+    flipped = "eu" if before.get("date_format") != "eu" else "us"
+
+    resp = await async_client.put(
+        "/api/v1/settings/",
+        json={"date_format": flipped, "external_url": "https://attacker.example"},
+        headers=manager,
+    )
+
+    assert resp.status_code == 403
+    after = (await async_client.get("/api/v1/settings/", headers=admin)).json()
+    assert after["date_format"] == before["date_format"]
+    assert after["external_url"] == before["external_url"]
+
+
+async def test_an_admin_still_changes_sign_in_settings(async_client: AsyncClient, admin):
+    resp = await async_client.put(
+        "/api/v1/settings/", json={"external_url": "https://bambuddy.example", "session_max_hours": 48}, headers=admin
+    )
+    assert resp.status_code == 200, resp.text
+    assert resp.json()["external_url"] == "https://bambuddy.example"
+
+
+async def test_an_admin_still_reaches_the_admin_only_routes(async_client: AsyncClient, admin):
+    resp = await async_client.post("/api/v1/auth/ldap/test", headers=admin)
+    assert resp.status_code != 403
+    resp = await async_client.get("/api/v1/settings/backup", headers=admin)
+    assert resp.status_code != 403
+
+
+async def _git_config(async_client: AsyncClient, admin) -> None:
+    from unittest.mock import AsyncMock, patch
+
+    with patch(
+        "backend.app.services.github_backup.github_backup_service.test_connection",
+        new=AsyncMock(
+            return_value={"success": True, "message": "ok", "permissions": {"push": True}, "is_private": True}
+        ),
+    ):
+        resp = await async_client.post(
+            "/api/v1/github-backup/config",
+            headers=admin,
+            json={
+                "repository_url": "https://github.com/test/repo",
+                "access_token": "ghp_testtoken123",  # pragma: allowlist secret — test fixture
+                "branch": "main",
+                "backup_settings": True,
+                "enabled": True,
+            },
+        )
+    assert resp.status_code == 200, resp.text
+
+
+@pytest.mark.parametrize(("who", "as_admin"), [("manager", False), ("admin", True)])
+async def test_a_git_restore_keeps_sign_in_settings_unless_an_admin_runs_it(
+    async_client: AsyncClient, admin, manager, who, as_admin
+):
+    from unittest.mock import AsyncMock, patch
+
+    await _git_config(async_client, admin)
+    headers = manager if who == "manager" else admin
+    run = AsyncMock(return_value={"success": True, "message": "ok", "results": {}})
+    preview = AsyncMock(return_value={"success": True, "message": "ok", "ref": "abc", "categories": []})
+    with (
+        patch("backend.app.services.github_restore.github_restore_service.run_restore", new=run),
+        patch("backend.app.services.github_restore.github_restore_service.preview", new=preview),
+    ):
+        restored = await async_client.post(
+            "/api/v1/github-backup/restore", json={"categories": ["settings"]}, headers=headers
+        )
+        previewed = await async_client.get("/api/v1/github-backup/restore/preview", headers=headers)
+
+    assert restored.status_code == 200, restored.text
+    assert previewed.status_code == 200, previewed.text
+    assert run.await_args.kwargs["as_admin"] is as_admin
+    assert preview.await_args.kwargs["as_admin"] is as_admin
+
+
+@pytest.mark.parametrize(("new", "old"), [(True, "true"), (False, "false"), (24, "24"), (None, None)])
+def test_a_value_is_the_same_once_stored(new, old):
+    from backend.app.api.routes.settings import _same_setting_value
+
+    assert _same_setting_value(new, old)
+
+
+@pytest.mark.parametrize(
+    ("new", "old"),
+    [
+        (True, "false"),
+        (48, "24"),
+        ("https://a.example", "https://b.example"),
+        # Stored as written: the text "None" and the spaces would reach the links.
+        (None, ""),
+        ("https://b.example ", "https://b.example"),
+    ],
+)
+def test_a_different_value_is_a_change(new, old):
+    from backend.app.api.routes.settings import _same_setting_value
+
+    assert not _same_setting_value(new, old)

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

@@ -143,3 +143,41 @@ async def test_a_retired_setting_in_an_old_backup_is_dropped_quietly(db_session)
 
     assert plan.unknown == ()
     assert "library_archive_mode" not in plan.stored
+
+
+class TestANonAdminRestoreKeepsTheSignInSettings:
+    """Through the settings page only an admin may change these, and only an
+    admin may set the mail server: a GitHub restore by a non-admin holding
+    github:restore and settings:update keeps them as they are here."""
+
+    PAYLOAD = {
+        "session_max_hours": 720,
+        "smtp_username": "relay@example.com",
+        "smtp_from_name": "Bambuddy",
+        "prometheus_enabled": True,
+        "external_url": "https://attacker.example",
+        "currency": "EUR",
+    }
+
+    @pytest.mark.asyncio
+    async def test_a_non_admin_restore_leaves_them(self, db_session):
+        from backend.app.services import github_restore
+
+        token = github_restore._restoring_as_admin.set(False)
+        try:
+            plan = await GitHubRestoreService()._plan_settings(db_session, dict(self.PAYLOAD))
+        finally:
+            github_restore._restoring_as_admin.reset(token)
+
+        assert {"session_max_hours", "smtp_username", "smtp_from_name", "prometheus_enabled", "external_url"} <= set(
+            plan.protected
+        )
+        assert plan.stored == {"currency": "EUR"}
+
+    @pytest.mark.asyncio
+    async def test_an_admin_restore_still_writes_them(self, db_session):
+        plan = await GitHubRestoreService()._plan_settings(db_session, dict(self.PAYLOAD))
+
+        assert "session_max_hours" not in plan.protected
+        assert plan.stored["session_max_hours"] == "720"
+        assert plan.stored["smtp_username"] == "relay@example.com"

+ 53 - 0
frontend/src/__tests__/components/GitHubBackupSettingsPermissions.test.tsx

@@ -113,3 +113,56 @@ describe('GitHubBackupSettings - github:restore gate', () => {
     );
   });
 });
+
+describe('GitHubBackupSettings - a full backup is admin-only', () => {
+  // It holds every password hash and 2FA secret, and restoring one replaces
+  // the users: the server refuses both to a non-administrator.
+  it('offers a non-admin neither the download nor the restore of a full backup', async () => {
+    mockConfiguredBackup();
+    mockUserWith(['settings:read', 'settings:backup', 'settings:restore']);
+
+    render(<GitHubBackupSettings />);
+
+    const download = await screen.findByRole('button', { name: /^Download$/i });
+    expect(download).toBeDisabled();
+    expect(screen.getByRole('button', { name: /^Restore$/i })).toBeDisabled();
+    expect(screen.getAllByText('Only an administrator can change this.').length).toBeGreaterThan(0);
+  });
+
+  it('shows a non-admin the backup folder read-only', async () => {
+    // Pointed into a folder the web server hands out, a full backup could be
+    // fetched without signing in.
+    mockConfiguredBackup();
+    mockUserWith(['settings:read', 'settings:update', 'settings:backup']);
+    server.use(
+      http.get('*/api/v1/local-backup/status', () =>
+        HttpResponse.json({
+          enabled: true, schedule: 'daily', time: '03:00', retention: 5, path: '', default_path: '/data/backups',
+          is_running: false, last_backup_at: null, last_status: null, last_message: null, next_run: null, timezone: 'UTC',
+        }),
+      ),
+      http.get('*/api/v1/local-backup/backups', () => HttpResponse.json([])),
+      http.get('*/api/v1/local-backup/path-check', () => HttpResponse.json({ ok: true })),
+    );
+
+    render(<GitHubBackupSettings />);
+
+    const label = await screen.findByText('Output Path');
+    const input = label.parentElement?.querySelector('input') as HTMLInputElement;
+    expect(input).toBeDisabled();
+  });
+
+  it('keeps them for an admin', async () => {
+    mockConfiguredBackup();
+    setAuthToken('test-token', 'session');
+    server.use(
+      http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+      http.get('*/api/v1/auth/me', () => HttpResponse.json({ id: 1, username: 'admin', is_admin: true, permissions: [] })),
+    );
+
+    render(<GitHubBackupSettings />);
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Download$/i })).toBeEnabled());
+    expect(screen.getByRole('button', { name: /^Restore$/i })).toBeEnabled();
+  });
+});

+ 65 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -2657,4 +2657,69 @@ describe('SettingsPage — settings changed outside the page (#2716)', () => {
     await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
     expect(puts[0].external_url).toBe(window.location.origin);
   });
+
+  describe('as a non-admin with settings:update', () => {
+    // Only an administrator may change the sign-in settings; the server refuses
+    // a save that changes one, which would take the rest of the save with it.
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 2, username: 'manager', role: 'user', is_active: true, is_admin: false,
+            groups: [{ id: 5, name: 'Settings managers' }],
+            permissions: ['settings:read', 'settings:update', 'users:read'],
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+      );
+      setAuthToken('test-token');
+    });
+
+    it('does not fill in the external URL from the browser', async () => {
+      served = { ...mockSettings, external_url: '' };
+      renderPage();
+      await screen.findByText(restoreLabel);
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      await changeOnServer({ spoolman_url: 'http://spoolman.example' });
+      await new Promise((resolve) => setTimeout(resolve, 1500));
+
+      expect(puts.filter((body) => body.external_url === window.location.origin)).toEqual([]);
+    });
+
+    it('shows the external URL read-only', async () => {
+      served = { ...mockSettings };
+      window.history.replaceState({}, '', '/?tab=network');
+      renderPage();
+
+      const input = await screen.findByPlaceholderText('http://192.168.1.100:8000');
+      await waitFor(() => expect(input).toBeDisabled());
+      expect(screen.getAllByText('Only an administrator can change this.').length).toBeGreaterThan(0);
+      window.history.replaceState({}, '', '/');
+    });
+
+    it('does not offer the connected apps', async () => {
+      // An app's redirect address decides where an admin's sign-in is sent.
+      served = { ...mockSettings };
+      window.history.replaceState({}, '', '/?tab=apikeys');
+      renderPage();
+
+      await waitFor(() => expect(screen.getByRole('button', { name: /^API Keys/ })).toBeInTheDocument());
+      await new Promise((resolve) => setTimeout(resolve, 300));
+      expect(screen.queryByText('Connected Apps')).toBeNull();
+      window.history.replaceState({}, '', '/');
+    });
+
+    it('does not offer the mail server and LDAP sub-tabs', async () => {
+      served = { ...mockSettings };
+      window.history.replaceState({}, '', '/?tab=users');
+      renderPage();
+
+      await waitFor(() => expect(screen.getByRole('button', { name: /^Two-Factor Auth/ })).toBeInTheDocument());
+      expect(screen.queryByRole('button', { name: /^LDAP/ })).toBeNull();
+      expect(screen.queryByRole('button', { name: /^Email Authentication/ })).toBeNull();
+      window.history.replaceState({}, '', '/');
+    });
+  });
 });

+ 14 - 4
frontend/src/components/GitHubBackupSettings.tsx

@@ -146,7 +146,7 @@ export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { t } = useTranslation();
-  const { hasPermission } = useAuth();
+  const { hasPermission, isAdmin } = useAuth();
 
   // All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
   // user without it gets a 403 the moment the modal opens its preview. Hide the
@@ -1105,11 +1105,13 @@ export function GitHubBackupSettings() {
                 <p className="text-sm text-bambu-gray">
                   {t('backup.completeBackupZip')}
                 </p>
+                {/* A full backup holds every password hash and 2FA secret. */}
+                {!isAdmin && <p className="text-xs text-bambu-gray mt-1">{t('settings.adminOnlySetting')}</p>}
               </div>
               <Button
                 variant="secondary"
                 size="sm"
-                disabled={isExporting || isRestoring}
+                disabled={isExporting || isRestoring || !isAdmin}
                 onClick={async () => {
                   setIsExporting(true);
                   setOperationStatus(t('backup.preparingBackup'));
@@ -1165,7 +1167,8 @@ export function GitHubBackupSettings() {
               <Button
                 variant="secondary"
                 size="sm"
-                disabled={isRestoring || isExporting}
+                disabled={isRestoring || isExporting || !isAdmin}
+                title={!isAdmin ? t('settings.adminOnlySetting') : undefined}
                 onClick={() => fileInputRef.current?.click()}
               >
                 <Upload className="w-4 h-4" />
@@ -1316,8 +1319,11 @@ export function GitHubBackupSettings() {
                     type="text"
                     value={localBackupPath}
                     onChange={(e) => setLocalBackupPath(e.target.value)}
-                    className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                    disabled={!isAdmin}
+                    title={!isAdmin ? t('settings.adminOnlySetting') : undefined}
+                    className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
                     onBlur={async () => {
+                      if (!isAdmin) return;
                       try {
                         await api.updateSettings({ local_backup_path: localBackupPath });
                         showToast(t('backup.settingsSaved'));
@@ -1437,6 +1443,8 @@ export function GitHubBackupSettings() {
                             </span>
                           </div>
                           <div className="flex items-center gap-1 flex-shrink-0">
+                            {isAdmin && (
+                            <>
                             <button
                               className="text-bambu-gray hover:text-bambu-green p-1"
                               title={t('backup.download')}
@@ -1463,6 +1471,8 @@ export function GitHubBackupSettings() {
                             >
                               <RotateCcw className="w-3.5 h-3.5" />
                             </button>
+                            </>
+                            )}
                             <button
                               className="text-bambu-gray hover:text-red-600 dark:hover:text-red-400 p-1"
                               onClick={() => setDeleteConfirmFile(file.filename)}

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

@@ -2930,6 +2930,7 @@ export default {
     externalUrlDescription: 'Die externe URL, unter der Bambuddy erreichbar ist. Wird für Benachrichtigungsbilder und externe Integrationen verwendet.',
     bambuddyUrl: 'Bambuddy-URL',
     externalUrlHint: 'Protokoll und Port angeben (z.B. http://192.168.1.100:8000)',
+    adminOnlySetting: 'Nur ein Administrator kann dies ändern.',
     ftpRetry: 'FTP-Wiederholung',
     ftpRetryDescription: 'FTP-Operationen bei unzuverlässigem Drucker-WLAN wiederholen. Gilt für 3MF-Downloads, Druck-Uploads, Zeitraffer-Downloads und Firmware-Updates.',
     autoRetryDescription: 'Fehlgeschlagene FTP-Operationen automatisch wiederholen',

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

@@ -2952,6 +2952,7 @@ export default {
     externalUrlDescription: 'The external URL where Bambuddy is accessible. Used for notification images and external integrations.',
     bambuddyUrl: 'Bambuddy URL',
     externalUrlHint: 'Include protocol and port (e.g., http://192.168.1.100:8000)',
+    adminOnlySetting: 'Only an administrator can change this.',
     ftpRetry: 'FTP Retry',
     ftpRetryDescription: 'Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.',
     autoRetryDescription: 'Automatically retry failed FTP operations',

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

@@ -2932,6 +2932,7 @@ export default {
     externalUrlDescription: 'La URL externa donde Bambuddy es accesible. Se usa para las imágenes de notificación y las integraciones externas.',
     bambuddyUrl: 'URL de Bambuddy',
     externalUrlHint: 'Incluya el protocolo y el puerto (p. ej., http://192.168.1.100:8000)',
+    adminOnlySetting: 'Solo un administrador puede cambiar esto.',
     ftpRetry: 'Reintento de FTP',
     ftpRetryDescription: 'Reintentar las operaciones FTP cuando el Wi-Fi de la impresora no es fiable. Se aplica a las descargas de 3MF, las subidas de impresión, las descargas de time-lapse y las actualizaciones de firmware.',
     autoRetryDescription: 'Reintentar automáticamente las operaciones FTP fallidas',

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

@@ -2877,6 +2877,7 @@ export default {
     externalUrlDescription: 'L\'URL externe où Bambuddy est accessible. Utilisée pour les images de notification et les intégrations externes.',
     bambuddyUrl: 'URL Bambuddy',
     externalUrlHint: 'Inclure le protocole et le port (ex : http://192.168.1.100:8000)',
+    adminOnlySetting: 'Seul un administrateur peut modifier ce paramètre.',
     ftpRetry: 'Réessai FTP',
     ftpRetryDescription: 'Réessayer les opérations FTP lorsque le WiFi de l\'imprimante est instable. S\'applique aux téléchargements 3MF, uploads d\'impression, téléchargements timelapse et mises à jour firmware.',
     autoRetryDescription: 'Réessayer automatiquement les opérations FTP échouées',

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

@@ -2876,6 +2876,7 @@ export default {
     externalUrlDescription: 'L\'URL esterno dove Bambuddy è accessibile. Usato per immagini di notifica e integrazioni esterne.',
     bambuddyUrl: 'URL Bambuddy',
     externalUrlHint: 'Includi protocollo e porta (es. http://192.168.1.100:8000)',
+    adminOnlySetting: 'Solo un amministratore può modificarlo.',
     ftpRetry: 'Riprova FTP',
     ftpRetryDescription: 'Riprova le operazioni FTP quando il WiFi della stampante è instabile. Si applica a download 3MF, upload stampe, download timelapse e aggiornamenti firmware.',
     autoRetryDescription: 'Riprova automaticamente le operazioni FTP fallite',

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

@@ -2929,6 +2929,7 @@ export default {
     externalUrlDescription: 'Bambuddyがアクセス可能な外部URL。通知画像や外部連携に使用されます。',
     bambuddyUrl: 'BambuddyのURL',
     externalUrlHint: 'プロトコルとポートを含めてください(例: http://192.168.1.100:8000)',
+    adminOnlySetting: 'これを変更できるのは管理者のみです。',
     ftpRetry: 'FTPリトライ',
     ftpRetryDescription: 'プリンターのWi-Fiが不安定な場合にFTP操作をリトライ。3MFダウンロード、印刷アップロード、タイムラプスダウンロード、ファームウェア更新に適用。',
     autoRetryDescription: '失敗したFTP操作を自動的にリトライ',

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

@@ -2788,6 +2788,7 @@ export default {
     externalUrlDescription: 'Bambuddy에 접근할 수 있는 외부 URL. 알림 이미지 및 외부 통합에 사용됩니다.',
     bambuddyUrl: 'Bambuddy URL',
     externalUrlHint: '프로토콜과 포트를 포함하세요 (예: http://192.168.1.100:8000)',
+    adminOnlySetting: '관리자만 변경할 수 있습니다.',
     ftpRetry: 'FTP 재시도',
     ftpRetryDescription: '프린터 WiFi가 불안정할 때 FTP 작업을 재시도합니다.',
     autoRetryDescription: '실패한 FTP 작업 자동 재시도',

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

@@ -2950,6 +2950,7 @@ export default {
     externalUrlDescription: 'De externe URL waarop Bambuddy bereikbaar is. Wordt gebruikt voor afbeeldingen in meldingen en externe integraties.',
     bambuddyUrl: 'Bambuddy-URL',
     externalUrlHint: 'Inclusief protocol en poort (bijv. http://192.168.1.100:8000)',
+    adminOnlySetting: 'Alleen een beheerder kan dit wijzigen.',
     ftpRetry: 'FTP opnieuw proberen',
     ftpRetryDescription: 'FTP-bewerkingen opnieuw proberen wanneer de wifi van de printer onbetrouwbaar is. Geldt voor 3MF-downloads, afdrukuploads, timelapse-downloads en firmware-updates.',
     autoRetryDescription: 'Mislukte FTP-bewerkingen automatisch opnieuw proberen',

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

@@ -2876,6 +2876,7 @@ export default {
     externalUrlDescription: 'A URL externa onde o Bambuddy está acessível. Usada para imagens de notificação e integrações externas.',
     bambuddyUrl: 'URL do Bambuddy',
     externalUrlHint: 'Inclua protocolo e porta (ex: http://192.168.1.100:8000)',
+    adminOnlySetting: 'Somente um administrador pode alterar isto.',
     ftpRetry: 'Tentativa FTP',
     ftpRetryDescription: 'Tentar novamente operações FTP quando o WiFi da impressora é instável. Aplica-se a downloads 3MF, uploads de impressão, downloads de timelapse e atualizações de firmware.',
     autoRetryDescription: 'Tentar novamente automaticamente operações FTP que falharam',

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

@@ -2792,6 +2792,7 @@ export default {
     externalUrlDescription: "Внешний адрес, по которому доступен Bambuddy. Используется для изображений в уведомлениях и внешних интеграций.",
     bambuddyUrl: "URL Bambuddy",
     externalUrlHint: "Укажите протокол и порт, например http://192.168.1.100:8000",
+    adminOnlySetting: "Изменить это может только администратор.",
     ftpRetry: "Повтор FTP-операций",
     ftpRetryDescription: "Повторять FTP-операции при нестабильном Wi‑Fi принтера. Применяется к скачиванию 3MF и таймлапсов, загрузке заданий и обновлению прошивки.",
     autoRetryDescription: "Автоматически повторять неудачные FTP-операции",

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

@@ -2949,6 +2949,7 @@ export default {
     externalUrlDescription: 'Den externa URL:en där Bambuddy kan nås. Används för notisbilder och externa integrationer.',
     bambuddyUrl: 'Bambuddy-URL',
     externalUrlHint: 'Inkludera protokoll och port (t.ex. http://192.168.1.100:8000)',
+    adminOnlySetting: 'Endast en administratör kan ändra detta.',
     ftpRetry: 'FTP-omförsök',
     ftpRetryDescription: 'Försök igen med FTP-operationer när skrivarens WiFi är opålitlig. Gäller för 3MF-nedladdningar, utskriftsuppladdningar, timelapse-nedladdningar och firmwareuppdateringar.',
     autoRetryDescription: 'Försök automatiskt igen med misslyckade FTP-operationer',

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

@@ -2933,6 +2933,7 @@ export default {
     externalUrlDescription: 'Bambuddy\'nin erişilebildiği harici URL. Bildirim görüntüleri ve harici entegrasyonlar için kullanılır.',
     bambuddyUrl: 'Bambuddy URL',
     externalUrlHint: 'Protokol ve port dahil edin (örn., http://192.168.1.100:8000)',
+    adminOnlySetting: 'Bunu yalnızca bir yönetici değiştirebilir.',
     ftpRetry: 'FTP Yeniden Dene',
     ftpRetryDescription: 'Yazıcı WiFi güvenilmez olduğunda FTP işlemlerini yeniden dene. 3MF indirmelerine, baskı yüklemelerine, zaman atlamalı video indirmelerine ve firmware güncellemelerine uygulanır.',
     autoRetryDescription: 'Başarısız FTP işlemlerini otomatik olarak yeniden dene',

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

@@ -2947,6 +2947,7 @@ export default {
     externalUrlDescription: "Зовнішній URL, де Bambuddy доступний. Використовується для зображень сповіщень і зовнішньої інтеграції.",
     bambuddyUrl: "Bambuddy URL",
     externalUrlHint: "Включити протокол і порт (наприклад, http://192.168.1.100:8000)",
+    adminOnlySetting: "Змінити це може лише адміністратор.",
     ftpRetry: "FTP Спробуйте ще раз",
     ftpRetryDescription: "Повторювати операції FTP за ненадійного Wi-Fi принтера. Застосовується до завантаження 3MF і таймлапсів, вивантаження завдань друку та оновлення прошивки.",
     autoRetryDescription: "Автоматично повторити невдалі операції FTP.",

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

@@ -2921,6 +2921,7 @@ export default {
     externalUrlDescription: 'Bambuddy可访问的外部URL。用于通知图像和外部集成。',
     bambuddyUrl: 'Bambuddy URL',
     externalUrlHint: '包含协议和端口(例如:http://192.168.1.100:8000)',
+    adminOnlySetting: '只有管理员可以更改此项。',
     ftpRetry: 'FTP重试',
     ftpRetryDescription: '当打印机WiFi不稳定时重试FTP操作。适用于3MF下载、打印上传、延时摄影下载和固件更新。',
     autoRetryDescription: '自动重试失败的FTP操作',

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

@@ -2921,6 +2921,7 @@ export default {
     externalUrlDescription: 'Bambuddy可存取的外部URL。用於通知影像和外部整合。',
     bambuddyUrl: 'Bambuddy URL',
     externalUrlHint: '包含協定和連接埠(例如:http://192.168.1.100:8000)',
+    adminOnlySetting: '只有管理員可以變更此項。',
     ftpRetry: 'FTP重試',
     ftpRetryDescription: '當印表機Wi-Fi 不穩定時重試FTP操作。適用於3MF下載、列印上傳、縮時攝影下載和韌體更新。',
     autoRetryDescription: '自動重試失敗的FTP操作',

+ 58 - 36
frontend/src/pages/SettingsPage.tsx

@@ -326,8 +326,12 @@ export function SettingsPage() {
     setSearchParams(searchParams, { replace: true });
   };
   // A link to Printer access lands non-admins on the Users sub-tab instead
+  // and the sign-in configuration sub-tabs, whose settings only an admin may change.
   const shownUsersSubTab: UsersSubTab =
-    usersSubTab === 'printer-access' && !(authEnabled && isAdmin) ? 'users' : usersSubTab;
+    (usersSubTab === 'printer-access' && !(authEnabled && isAdmin)) ||
+    ((usersSubTab === 'email' || usersSubTab === 'ldap') && !isAdmin)
+      ? 'users'
+      : usersSubTab;
   // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
   // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
   const initialQueueSub: 'dispatch' | 'pipelines' =
@@ -1128,10 +1132,11 @@ export function SettingsPage() {
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
-      // Auto-detect external_url from browser if not set
+      // Auto-detect external_url from browser if not set. Admins only: only an
+      // admin may change it, and the detected value is saved like an edit.
       const settingsWithExternalUrl = {
         ...settings,
-        external_url: settings.external_url || window.location.origin,
+        external_url: settings.external_url || (isAdmin ? window.location.origin : settings.external_url),
       };
       // The baseline is the raw server row, not this adjusted copy: a detected
       // external_url has to read as a local change so it still gets persisted.
@@ -1142,7 +1147,7 @@ export function SettingsPage() {
         isInitialLoadRef.current = false;
       }, 100);
     }
-  }, [settings, localSettings]);
+  }, [settings, localSettings, isAdmin]);
 
   // #2716: reconcile a moved server snapshot into the local copy. A field the
   // user has not touched since the last reconcile takes the server's value; a
@@ -3068,8 +3073,11 @@ export function SettingsPage() {
                   value={localSettings.external_url ?? ''}
                   onChange={(e) => updateSetting('external_url', e.target.value)}
                   placeholder="http://192.168.1.100:8000"
-                  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"
+                  disabled={!isAdmin}
+                  title={!isAdmin ? t('settings.adminOnlySetting') : undefined}
+                  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 disabled:opacity-50"
                 />
+                {!isAdmin && <p className="text-xs text-bambu-gray mt-1">{t('settings.adminOnlySetting')}</p>}
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('settings.externalUrlHint')}
                 </p>
@@ -3499,12 +3507,19 @@ export function SettingsPage() {
                 <div>
                   <p className="text-white">{t('settings.enableMetricsEndpoint')}</p>
                   <p className="text-xs text-bambu-gray">{t('settings.prometheusDescription')}</p>
+                  {!isAdmin && (
+                    <p id="prometheus-admin-only" className="text-xs text-bambu-gray mt-1">
+                      {t('settings.adminOnlySetting')}
+                    </p>
+                  )}
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                     type="checkbox"
                     checked={localSettings.prometheus_enabled ?? false}
                     onChange={(e) => updateSetting('prometheus_enabled', e.target.checked)}
+                    disabled={!isAdmin}
+                    aria-describedby={!isAdmin ? 'prometheus-admin-only' : undefined}
                     className="sr-only peer"
                   />
                   <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
@@ -3521,6 +3536,7 @@ export function SettingsPage() {
                       type="password"
                       value={localSettings.prometheus_token ?? ''}
                       onChange={(e) => updateSetting('prometheus_token', e.target.value)}
+                      disabled={!isAdmin}
                       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"
@@ -5036,7 +5052,8 @@ export function SettingsPage() {
 
             {/* Connected apps: "Sign in with Bambuddy" for external applications.
                 Admin-only, like the settings it sits between. */}
-            {hasPermission('settings:update') && (
+            {/* Admin-only: an app's redirect address decides where an admin's sign-in goes. */}
+            {isAdmin && (
               <Card className="mt-6">
                 <CardHeader>
                   <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-connected-apps">
@@ -6770,34 +6787,38 @@ export function SettingsPage() {
                 {t('printerAccess.tab')}
               </button>
             )}
-            <button
-              onClick={() => selectUsersSubTab('email')}
-              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-                shownUsersSubTab === 'email'
-                  ? 'text-bambu-green border-bambu-green'
-                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-              }`}
-            >
-              <Mail className="w-4 h-4" />
-              {t('settings.tabs.emailAuth') || 'Email Authentication'}
-              {advancedAuthStatus?.advanced_auth_enabled && (
-                <span className="w-2 h-2 rounded-full bg-green-400" />
-              )}
-            </button>
-            <button
-              onClick={() => selectUsersSubTab('ldap')}
-              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-                shownUsersSubTab === 'ldap'
-                  ? 'text-bambu-green border-bambu-green'
-                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-              }`}
-            >
-              <Shield className="w-4 h-4" />
-              {t('settings.tabs.ldap') || 'LDAP'}
-              {ldapStatus?.ldap_enabled && (
-                <span className="w-2 h-2 rounded-full bg-green-400" />
-              )}
-            </button>
+            {isAdmin && (
+              <>
+                <button
+                  onClick={() => selectUsersSubTab('email')}
+                  className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
+                    shownUsersSubTab === 'email'
+                      ? 'text-bambu-green border-bambu-green'
+                      : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+                  }`}
+                >
+                  <Mail className="w-4 h-4" />
+                  {t('settings.tabs.emailAuth') || 'Email Authentication'}
+                  {advancedAuthStatus?.advanced_auth_enabled && (
+                    <span className="w-2 h-2 rounded-full bg-green-400" />
+                  )}
+                </button>
+                <button
+                  onClick={() => selectUsersSubTab('ldap')}
+                  className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
+                    shownUsersSubTab === 'ldap'
+                      ? 'text-bambu-green border-bambu-green'
+                      : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+                  }`}
+                >
+                  <Shield className="w-4 h-4" />
+                  {t('settings.tabs.ldap') || 'LDAP'}
+                  {ldapStatus?.ldap_enabled && (
+                    <span className="w-2 h-2 rounded-full bg-green-400" />
+                  )}
+                </button>
+              </>
+            )}
             <button
               onClick={() => selectUsersSubTab('twofa')}
               className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
@@ -6924,6 +6945,7 @@ export function SettingsPage() {
                   <CardContent>
                     <p className="text-sm text-bambu-gray mb-4">
                       {t('settings.sessionPolicy.description')}
+                      {!isAdmin && <> {t('settings.adminOnlySetting')}</>}
                     </p>
                     <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
                       {[
@@ -6938,7 +6960,7 @@ export function SettingsPage() {
                             key={preset.hours}
                             type="button"
                             onClick={() => updateSetting('session_max_hours', preset.hours)}
-                            disabled={authEnabled && !hasPermission('settings:update')}
+                            disabled={!isAdmin}
                             className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
                               isActive
                                 ? 'bg-bambu-green text-white'
@@ -6960,7 +6982,7 @@ export function SettingsPage() {
                             if (Number.isNaN(raw)) return;
                             updateSetting('session_max_hours', Math.max(1, Math.min(720, raw)));
                           }}
-                          disabled={authEnabled && !hasPermission('settings:update')}
+                          disabled={!isAdmin}
                           aria-label={t('settings.sessionPolicy.customHoursLabel')}
                           className="w-20 px-2 py-2 bg-bambu-dark-tertiary text-white text-sm rounded-lg border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none disabled:opacity-50"
                         />

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AdditionalSection-fSMbPuw9.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AmsUnitCard-D95k25gn.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ArchivesPage-ChOfoAft.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/AssignToAmsModal-D-HyELjj.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CamWallPage-BhKS7WgK.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraDiagnoseModal-CxHb-nft.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraPage-Bn6vIwqz.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/CameraWall-DXnerYiu.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ColumnConfigModal-HFameg5X.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DhoIx4UF.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ConnectAuthorizePage-DTxC60g_.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ContextMenu-iqRtsVvp.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ExternalLinkPage-CZtw6CmJ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
static/assets/FileManagerPage-CzqeqocU.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FileUploadModal-DMF2Skp3.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/FinancePage-BHp7O39Y.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/GCodeViewerPage-8zSaunAN.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/GroupEditPage-BoBWZwwG.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ImagePreviewModal-BaWJSEwq.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/InventoryPage-Co_zCScb.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/LibraryTrashPage-DLOayGs5.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/LoginPage-0iVwbUWW.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/MaintenancePage-Cvg191n3.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ModelSourcesPage-DOAN1wTC.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/NotificationsPage-LnDq2u-R.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
static/assets/PdfPreviewModal-ChXITzbX.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PhotoGalleryModal-BNTfiVvo.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrinterLocationsPage-pVIP3bvT.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/PrintersPage-CfJjO0qZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProfilesPage-dHErhNdd.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectDetailPage-Bazq3GV1.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ProjectsPage-Bw4PfMIZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/QueuePage-8xZGdwoi.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/RichTextEditor-CeJHGeBA.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-Bs507TsR.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SettingsPage-Cx0B8bP7.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SetupPage-CMlk7c2v.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SliceModal-ukdaZ67l.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyAmsPage-aVlmMXEF.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage--rvYD3FM.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyDashboard-BVnc7UcE.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-B_cwibVW.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 1
static/assets/SpoolBuddySettingsPage-uPp_CSNU.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-sNxvQivE.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DgUbBTpb.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StatsPage-tPLr-2-Y.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/StreamOverlayPage-cr_K9fLF.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/SystemInfoPage-D85udD2P.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/de-XCWiEtoC.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/es-CiHSQ5Sz.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/fr-pJmlpPHb.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-BtnlYHam.js


+ 1 - 1
static/assets/inventorySearch-E6zT5oX-.js → static/assets/inventorySearch-CQDOs7S9.js

@@ -1 +1 @@
-import{mt as e}from"./index-BgUt0B-1.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{mt as e}from"./index-BtnlYHam.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};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/it-LoVD1KO1.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ja-CUYVJKn_.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ko-BGnwoMIV.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/locationSensorDefaults-BlWAmrha.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/nl-Ta01VgQl.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/pdf-CIUI3b0l.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/pt-BR-9m186HdZ.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/ru-DuR5vIFc.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/sv-CPcXdzfs.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/tr-q8_fOci2.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/uk-BzyvovYn.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/utils-RO7t3LDD.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/zh-CN-KyPS6omi.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/zh-TW-wfZ6fT1e.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-BgUt0B-1.js"></script>
+    <script type="module" crossorigin src="/assets/index-BtnlYHam.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-1U8CP3-A.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DNxj-7q3.js">

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff