Преглед изворни кода

fix(settings): pages work for users without the settings permission

Pages read install settings from GET /settings, which needs settings:read;
for anyone else they fell back to defaults. With billing on, the print
dialog hid the cost centre picker and the print was refused. The UI
settings these pages need now come from /settings/ui-flags, which any
signed-in user may read, through a useUiSettings hook. G-code snippets are
reported only as set or not.
maziggy пре 6 дана
родитељ
комит
4c15689267
100 измењених фајлова са 381 додато и 154 уклоњено
  1. 1 0
      CHANGELOG.md
  2. 41 1
      backend/app/api/routes/settings.py
  3. 45 1
      backend/tests/integration/test_settings_ui_flags_3023.py
  4. 1 1
      frontend/src/__tests__/components/ConnectedAppsSection.test.tsx
  5. 3 1
      frontend/src/__tests__/components/PrintModalBilling.test.tsx
  6. 6 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx
  7. 6 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyTopBar.test.tsx
  8. 65 0
      frontend/src/__tests__/hooks/useUiSettings.test.tsx
  9. 51 0
      frontend/src/__tests__/hooks/useUnknownTagPrompt.test.tsx
  10. 3 0
      frontend/src/__tests__/mocks/handlers.ts
  11. 17 13
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  12. 6 0
      frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx
  13. 6 0
      frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx
  14. 6 0
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  15. 12 7
      frontend/src/api/client.ts
  16. 2 4
      frontend/src/components/AMSHistoryModal.tsx
  17. 2 5
      frontend/src/components/AssignSpoolModal.tsx
  18. 2 1
      frontend/src/components/BatchOrdersView.tsx
  19. 2 1
      frontend/src/components/ForecastPanel.tsx
  20. 2 4
      frontend/src/components/HeaterHistoryModal.tsx
  21. 5 3
      frontend/src/components/LocationSensorOptionsModal.tsx
  22. 3 2
      frontend/src/components/ManyfoldTab.tsx
  23. 2 2
      frontend/src/components/ModelViewerModal.tsx
  24. 2 4
      frontend/src/components/NotificationLogViewer.tsx
  25. 2 4
      frontend/src/components/PrintModal/FilamentMapping.tsx
  26. 3 2
      frontend/src/components/PrintModal/PrinterSelector.tsx
  27. 5 7
      frontend/src/components/PrintModal/index.tsx
  28. 2 4
      frontend/src/components/RunWithPipelineModal.tsx
  29. 2 5
      frontend/src/components/SpoolFormModal.tsx
  30. 2 6
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  31. 2 4
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  32. 2 4
      frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx
  33. 5 3
      frontend/src/contexts/ThemeContext.tsx
  34. 26 0
      frontend/src/hooks/useUiSettings.ts
  35. 3 2
      frontend/src/hooks/useUnknownTagPrompt.ts
  36. 2 4
      frontend/src/pages/ArchivesPage.tsx
  37. 2 5
      frontend/src/pages/FileManagerPage.tsx
  38. 3 5
      frontend/src/pages/InventoryPage.tsx
  39. 2 4
      frontend/src/pages/MakerworldPage.tsx
  40. 2 5
      frontend/src/pages/NotificationsPage.tsx
  41. 2 1
      frontend/src/pages/PrintersPage.tsx
  42. 2 4
      frontend/src/pages/ProjectDetailPage.tsx
  43. 2 4
      frontend/src/pages/ProjectsPage.tsx
  44. 4 5
      frontend/src/pages/QueuePage.tsx
  45. 1 0
      frontend/src/pages/SettingsPage.tsx
  46. 2 4
      frontend/src/pages/StatsPage.tsx
  47. 2 4
      frontend/src/pages/SystemInfoPage.tsx
  48. 2 5
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  49. 3 8
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  50. 0 0
      static/assets/AdditionalSection-DARQM-bp.js
  51. 0 0
      static/assets/AmsUnitCard-B7uKwUi9.js
  52. 0 0
      static/assets/ArchivesPage-B7e49n2x.js
  53. 0 0
      static/assets/ArchivesPage-Cc-Afs6N.js
  54. 0 0
      static/assets/AreaChart-CwTUicZX.js
  55. 0 0
      static/assets/AssignToAmsModal-CdZZwdGB.js
  56. 0 0
      static/assets/AssignToAmsModal-DxMRLw9M.js
  57. 0 0
      static/assets/AuthContext-C84ko0T4.js
  58. 1 1
      static/assets/Button-CPK1NMqY.js
  59. 0 0
      static/assets/CamWallPage-CT2wYxz_.js
  60. 0 0
      static/assets/CamWallPage-DLMO7KTj.js
  61. 0 0
      static/assets/CameraDiagnoseModal-DFSm4iqj.js
  62. 0 0
      static/assets/CameraDiagnoseModal-DzNRLl7L.js
  63. 0 0
      static/assets/CameraPage-BHoSMv8h.js
  64. 0 0
      static/assets/CameraPage-DcsCiwvU.js
  65. 0 0
      static/assets/CameraWall-CjEnvIIp.js
  66. 1 1
      static/assets/Card-DnEKZXBw.js
  67. 0 0
      static/assets/CartesianChart-DzNodXkf.js
  68. 0 0
      static/assets/ColumnConfigModal-CS88vSHi.js
  69. 0 0
      static/assets/ConfigureAmsSlotModal-BfjQbqLi.js
  70. 0 0
      static/assets/ConfigureAmsSlotModal-oO1BYxjJ.js
  71. 0 0
      static/assets/ConnectAuthorizePage--tchC0OA.js
  72. 0 0
      static/assets/ConnectAuthorizePage-C9MyxLc0.js
  73. 0 0
      static/assets/ContextMenu-C5OLyWWj.js
  74. 0 0
      static/assets/CopyButton-DaEvAsbn.js
  75. 0 0
      static/assets/ErrorBarContext-DRYme72x.js
  76. 0 0
      static/assets/ExternalLinkPage-DOH3XsR2.js
  77. 0 1
      static/assets/FileManagerPage-B6moaOZC.js
  78. 1 0
      static/assets/FileManagerPage-D2IkrJLB.js
  79. 0 0
      static/assets/FileUploadModal-BxaCrz7n.js
  80. 0 0
      static/assets/FinancePage-76NLYr_U.js
  81. 0 0
      static/assets/FinancePage-BUiQzjji.js
  82. 0 0
      static/assets/GCodeViewerPage-DygIdlIX.js
  83. 0 0
      static/assets/GcodeToolpathViewer-Dx9neFgR.js
  84. 0 0
      static/assets/GroupEditPage-85AagC41.js
  85. 0 0
      static/assets/ImagePreviewModal-n_Jj9jPx.js
  86. 0 0
      static/assets/InventoryPage-BTq0yWoy.js
  87. 0 0
      static/assets/InventoryPage-DBPc_AXQ.js
  88. 0 0
      static/assets/LibraryTrashPage-DZqGDPJ2.js
  89. 0 0
      static/assets/LibraryTrashPage-DjKLrLls.js
  90. 0 0
      static/assets/LoginPage-Czd50HzW.js
  91. 0 0
      static/assets/LoginPage-DuTCvgew.js
  92. 0 0
      static/assets/MaintenancePage-DnqHbNcD.js
  93. 0 0
      static/assets/MaintenancePage-fMhO4x0n.js
  94. 0 0
      static/assets/ModelSourcesPage-CaaXYLac.js
  95. 0 0
      static/assets/ModelSourcesPage-DNijFRF-.js
  96. 0 0
      static/assets/NotificationsPage-Cq-P-397.js
  97. 0 0
      static/assets/NotificationsPage-DoioRbHA.js
  98. 1 1
      static/assets/OverlayFrame-DO4-H9rs.js
  99. 0 1
      static/assets/PdfPreviewModal-B6sVcYE5.js
  100. 1 0
      static/assets/PdfPreviewModal-DnyjqrUQ.js

+ 1 - 0
CHANGELOG.md

@@ -163,6 +163,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Pages work for users without the settings permission** — Users in a group without **settings:read** could not read the install settings, so pages that need a few of them fell back to defaults. With billing on, the print dialog hid the cost centre picker and the print was then refused for lacking one. The print dialog also lost its print option and stagger defaults, currency and filament preference. The queue showed the wrong order with shortest job first on, the File Manager lost **Slice**, filament warnings an administrator had turned off still appeared, adding an unknown spool from its tag put it in the built-in inventory even in Spoolman mode, and the theme, currency and forecast lead time fell back to defaults. These settings now reach every signed-in user, without the credentials **settings:read** also shows; G-code snippets are only reported as set or not.
 - **SpoolBuddy writes the Spoolman spool to the tag in Spoolman mode** — Built-in spools are kept after switching to Spoolman. When one of them had the same id as the Spoolman spool being written, SpoolBuddy wrote the built-in spool's material, colour and weight to the tag and linked the tag to that hidden spool, while reporting success. The Spoolman spool never got the tag, so scanning it later found nothing. Writing a tag now uses only the inventory in use.
 - **Budget held by prints that ended is released** — Before a print without a user (authentication off, or its user deleted while it printed) was charged to its cost centre, it kept its budget reservation for good, so the cost centre showed that much less budget left. At the next start, reservations of prints that have finished or were removed from the queue are released. Only the reserved amount is freed; charges already booked don't change.
 - **A pipeline run remembers how each copy ended** — A copy a slicer pipeline sent to the queue only showed its outcome while its queue entry existed. Once the entry was deleted (from the queue page, with its archive, or with its user) the run could no longer tell a printed copy from one that never ran: it stayed **dispatching** forever, and **Retry failed** could not tell what to retry. Each copy now keeps its final state (completed, failed or cancelled) when its queue entry ends, and removing a copy that hadn't printed yet counts as cancelled. Copies whose entry was already gone before this update show as **unknown**: never retried, and counted as finished, so the run completes.

+ 41 - 1
backend/app/api/routes/settings.py

@@ -633,6 +633,43 @@ _UI_FLAG_FIELDS: tuple[str, ...] = (
     # update check ran even where it had been switched off.
     "currency",
     "check_updates",
+    # Read by the print dialog, queue, file manager, inventory and SpoolBuddy
+    # pages, which users without SETTINGS_READ open too: the 403 dropped them
+    # all to their defaults (with billing on, the print dialog hid the cost
+    # centre picker and the print was then refused for lacking one).
+    "queue_shortest_first",
+    "use_slicer_api",
+    "open_in_slicer",
+    "preferred_slicer",
+    "disable_filament_warnings",
+    "prefer_lowest_filament",
+    "per_printer_mapping_expanded",
+    "default_filament_cost",
+    "default_bed_levelling",
+    "default_flow_cali",
+    "default_vibration_cali",
+    "default_layer_inspect",
+    "default_timelapse",
+    "default_nozzle_offset_cali",
+    "default_confirm_outcome",
+    "stagger_group_size",
+    "stagger_interval_minutes",
+    "library_disk_warning_gb",
+    "low_stock_threshold",
+    "location_sensor_poll_interval",
+    "location_sensor_alert_defaults",
+    "pipeline_max_copies",
+    "language",
+    "dark_style",
+    "dark_background",
+    "dark_accent",
+    "light_style",
+    "light_background",
+    "light_accent",
+    # Which inventory a new spool goes to (unknown-tag prompt), and the
+    # forecast's default lead time.
+    "spoolman_enabled",
+    "forecast_global_lead_time_days",
 )
 
 
@@ -656,7 +693,10 @@ async def get_ui_flags(
     """
     full = await _build_settings_response(db, is_api_key=False)
     dumped = full.model_dump()
-    return {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+    flags = {key: dumped[key] for key in _UI_FLAG_FIELDS if key in dumped}
+    # Whether G-code snippets are set up, not the snippets themselves.
+    flags["has_gcode_snippets"] = bool(dumped.get("gcode_snippets"))
+    return flags
 
 
 @router.get("/check-ffmpeg")

+ 45 - 1
backend/tests/integration/test_settings_ui_flags_3023.py

@@ -138,7 +138,7 @@ class TestTheBoundaryItDraws:
 @pytest.mark.integration
 class TestWhatItExposes:
     @pytest.mark.asyncio
-    async def test_the_field_set_is_exactly_these_four(self, async_client: AsyncClient):
+    async def test_the_field_set_is_exactly_these(self, async_client: AsyncClient):
         """Pinned like the /ui-preferences set: anything added here is readable
         by every signed-in user, so adding one should require editing this."""
         resp = await async_client.get(FLAGS_URL)
@@ -147,8 +147,50 @@ class TestWhatItExposes:
             "user_notifications_enabled",
             "currency",
             "check_updates",
+            "queue_shortest_first",
+            "use_slicer_api",
+            "open_in_slicer",
+            "preferred_slicer",
+            "disable_filament_warnings",
+            "prefer_lowest_filament",
+            "per_printer_mapping_expanded",
+            "default_filament_cost",
+            "default_bed_levelling",
+            "default_flow_cali",
+            "default_vibration_cali",
+            "default_layer_inspect",
+            "default_timelapse",
+            "default_nozzle_offset_cali",
+            "default_confirm_outcome",
+            "stagger_group_size",
+            "stagger_interval_minutes",
+            "library_disk_warning_gb",
+            "low_stock_threshold",
+            "location_sensor_poll_interval",
+            "location_sensor_alert_defaults",
+            "pipeline_max_copies",
+            "language",
+            "dark_style",
+            "dark_background",
+            "dark_accent",
+            "light_style",
+            "light_background",
+            "light_accent",
+            "spoolman_enabled",
+            "forecast_global_lead_time_days",
+            "has_gcode_snippets",
         }
 
+    @pytest.mark.asyncio
+    async def test_gcode_snippets_are_reported_as_set_not_shown(self, async_client: AsyncClient, db_session):
+        db_session.add(Settings(key="gcode_snippets", value='{"X1C": {"start": "M117 SNIPPET_TEXT"}}'))
+        await db_session.commit()
+
+        resp = await async_client.get(FLAGS_URL)
+
+        assert resp.json()["has_gcode_snippets"] is True
+        assert "SNIPPET_TEXT" not in resp.text
+
     @pytest.mark.asyncio
     async def test_no_credential_ever_appears(self, async_client: AsyncClient, db_session):
         for i, key in enumerate(
@@ -167,3 +209,5 @@ class TestWhatItExposes:
         public = (await async_client.get("/api/v1/settings/ui-preferences")).json()
         assert "billing_enabled" not in public
         assert "user_notifications_enabled" not in public
+        assert "has_gcode_snippets" not in public
+        assert "queue_shortest_first" not in public

+ 1 - 1
frontend/src/__tests__/components/ConnectedAppsSection.test.tsx

@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { api, type ConnectedApp } from '../../api/client';
 
-const mockUseAuth = { authEnabled: true };
+const mockUseAuth = { authEnabled: true, hasPermission: () => true };
 vi.mock('../../contexts/AuthContext', async (importOriginal) => {
   const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
   return { ...actual, useAuth: () => mockUseAuth };

+ 3 - 1
frontend/src/__tests__/components/PrintModalBilling.test.tsx

@@ -110,7 +110,9 @@ describe('PrintModal billing payloads', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     server.use(
-      http.get('/api/v1/settings/', () => {
+      // This user may not read /settings: what the dialog needs comes from
+      // /settings/ui-flags.
+      http.get('/api/v1/settings/ui-flags', () => {
         return HttpResponse.json({
           currency: 'USD',
           default_filament_cost: 25,

+ 6 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyLayout.test.tsx

@@ -11,6 +11,12 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { SpoolBuddyLayout } from '../../../components/spoolbuddy/SpoolBuddyLayout';
 import { ToastProvider } from '../../../contexts/ToastContext';
 
+// The kiosk runs signed in; these tests render it without the auth provider.
+vi.mock('../../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => ({ hasPermission: () => true }) };
+});
+
 vi.mock('react-i18next', () => ({
   useTranslation: () => ({
     t: (_key: string, fallback: string) => fallback,

+ 6 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyTopBar.test.tsx

@@ -11,6 +11,12 @@ import React from 'react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { SpoolBuddyTopBar } from '../../../components/spoolbuddy/SpoolBuddyTopBar';
 
+// The kiosk runs signed in; these tests render it without the auth provider.
+vi.mock('../../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => ({ hasPermission: () => true }) };
+});
+
 vi.mock('react-i18next', () => ({
   useTranslation: () => ({
     t: (_key: string, fallback: string) => fallback,

+ 65 - 0
frontend/src/__tests__/hooks/useUiSettings.test.tsx

@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { hasGcodeSnippets, useUiSettings } from '../../hooks/useUiSettings';
+
+const permissions = vi.hoisted(() => ({ granted: [] as string[] }));
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => ({ hasPermission: (p: string) => permissions.granted.includes(p) }) };
+});
+
+function wrapper({ children }: { children: ReactNode }) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+}
+
+describe('useUiSettings', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json({ billing_enabled: true, smtp_host: 'mail.example' })),
+      http.get('/api/v1/settings/ui-flags', () => HttpResponse.json({ billing_enabled: true, has_gcode_snippets: true })),
+      http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({ time_format: '24h' })),
+    );
+  });
+
+  it('gives a user who may read the settings all of them', async () => {
+    permissions.granted = ['settings:read'];
+    const { result } = renderHook(() => useUiSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data).toEqual({ billing_enabled: true, smtp_host: 'mail.example' });
+  });
+
+  it('gives any other user what the pages need without a refused request', async () => {
+    permissions.granted = [];
+    let refused = false;
+    server.use(
+      http.get('/api/v1/settings/', () => {
+        refused = true;
+        return new HttpResponse(null, { status: 403 });
+      }),
+    );
+    const { result } = renderHook(() => useUiSettings(), { wrapper });
+
+    await waitFor(() => expect(result.current.data).toBeDefined());
+    expect(result.current.data).toEqual({ billing_enabled: true, has_gcode_snippets: true, time_format: '24h' });
+    expect(refused).toBe(false);
+  });
+});
+
+describe('hasGcodeSnippets', () => {
+  it.each([
+    [{ gcode_snippets: '{"X1C": {}}' }, true],
+    [{ has_gcode_snippets: true }, true],
+    [{ gcode_snippets: '' }, false],
+    [{}, false],
+    [undefined, false],
+  ])('%j -> %s', (settings, expected) => {
+    expect(hasGcodeSnippets(settings)).toBe(expected);
+  });
+});

+ 51 - 0
frontend/src/__tests__/hooks/useUnknownTagPrompt.test.tsx

@@ -0,0 +1,51 @@
+import { describe, it, expect, vi } from 'vitest';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { api } from '../../api/client';
+import { useUnknownTagPrompt } from '../../hooks/useUnknownTagPrompt';
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  // A signed-in user who may not read /settings.
+  return { ...actual, useAuth: () => ({ user: { id: 3 }, authEnabled: true, hasPermission: () => false }) };
+});
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return { ...actual, useToast: () => ({ showToast: vi.fn() }) };
+});
+
+function wrapper({ children }: { children: ReactNode }) {
+  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
+}
+
+describe('useUnknownTagPrompt', () => {
+  it('adds the spool to Spoolman in Spoolman mode, for a user who may not read the settings', async () => {
+    let served = false;
+    server.use(
+      http.get('/api/v1/settings/ui-flags', () => {
+        served = true;
+        return HttpResponse.json({ spoolman_enabled: true });
+      }),
+    );
+    const toSpoolman = vi.spyOn(api, 'createSpoolmanSpoolFromSlot').mockResolvedValue({} as never);
+    const toBuiltIn = vi.spyOn(api, 'createSpoolFromSlot').mockResolvedValue({} as never);
+    const { result } = renderHook(() => useUnknownTagPrompt(), { wrapper });
+
+    await act(async () => {
+      window.dispatchEvent(
+        new CustomEvent('unknown-tag', { detail: { printer_id: 1, ams_id: 0, tray_id: 2, tray_type: 'PLA' } }),
+      );
+    });
+    await waitFor(() => expect(result.current.prompt).not.toBeNull());
+    await waitFor(() => expect(served).toBe(true));
+    await act(async () => {});
+    await act(async () => result.current.confirm());
+
+    await waitFor(() => expect(toSpoolman).toHaveBeenCalledWith({ printer_id: 1, ams_id: 0, tray_id: 2 }));
+    expect(toBuiltIn).not.toHaveBeenCalled();
+  });
+});

+ 3 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -552,6 +552,9 @@ export const handlers = [
   // Per-file project print progress (#1897) — empty means "no completed runs"
   http.get('/api/v1/projects/:id/file-progress', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),
+  // What a user who may not read /settings gets instead (useUiSettings).
+  http.get('/api/v1/settings/ui-flags', () => HttpResponse.json({})),
+  http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({})),
   http.get('/api/v1/settings/check-ffmpeg', () =>
     HttpResponse.json({ available: false, version: null })
   ),

+ 17 - 13
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -12,6 +12,14 @@ import { setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 
+// The page reads its settings from /settings, or from /settings/ui-flags for a
+// user who may not read /settings, as in the permission tests below.
+const settingsHandlers = (body: Record<string, unknown>) => [
+  http.get('/api/v1/settings/', () => HttpResponse.json(body)),
+  http.get('/api/v1/settings/ui-flags', () => HttpResponse.json(body)),
+  http.get('/api/v1/settings/ui-preferences', () => HttpResponse.json({})),
+];
+
 // Only the protocol-handler launch is stubbed — it would navigate the jsdom
 // window. Everything else in the module is a pure predicate, so keep the real
 // implementations: isSliceableFilename decides which rows even offer the
@@ -2047,7 +2055,7 @@ describe('FileManagerPage', () => {
         // about the handoff mechanics and the permission gate, not about which
         // slicer, so they run against OrcaSlicer. The Bambu Studio side is
         // covered by its own tests below.
-        http.get('/api/v1/settings/', () => HttpResponse.json({ preferred_slicer: 'orcaslicer' })),
+        ...settingsHandlers({ preferred_slicer: 'orcaslicer' }),
       );
     });
 
@@ -2086,7 +2094,7 @@ describe('FileManagerPage', () => {
 
     it('opens the in-app SliceModal when the slicer API is enabled', async () => {
       server.use(
-        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+        ...settingsHandlers({ use_slicer_api: true }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2109,7 +2117,7 @@ describe('FileManagerPage', () => {
     // worked. Nothing about STL was special; the card was just the shortest.
     it('keeps the first menu entry out of the card so it cannot be clipped (#2846)', async () => {
       server.use(
-        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+        ...settingsHandlers({ use_slicer_api: true }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2131,7 +2139,7 @@ describe('FileManagerPage', () => {
     // could only fail, with an error that blamed the file.
     it('hides the desktop handoff for an STL when the target is Bambu Studio', async () => {
       server.use(
-        http.get('/api/v1/settings/', () => HttpResponse.json({ preferred_slicer: 'bambu_studio' })),
+        ...settingsHandlers({ preferred_slicer: 'bambu_studio' }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2147,9 +2155,7 @@ describe('FileManagerPage', () => {
       // preferred_slicer, which drives the sidecar -- decides whether an STL
       // can be handed over at all.
       server.use(
-        http.get('/api/v1/settings/', () =>
-          HttpResponse.json({ preferred_slicer: 'bambu_studio', open_in_slicer: 'orcaslicer' }),
-        ),
+        ...settingsHandlers({ preferred_slicer: 'bambu_studio', open_in_slicer: 'orcaslicer' }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2168,9 +2174,7 @@ describe('FileManagerPage', () => {
       // The restriction is on the URL handoff, not on the file: the sidecar
       // slices an STL regardless of which desktop slicer is configured.
       server.use(
-        http.get('/api/v1/settings/', () =>
-          HttpResponse.json({ use_slicer_api: true, preferred_slicer: 'bambu_studio' }),
-        ),
+        ...settingsHandlers({ use_slicer_api: true, preferred_slicer: 'bambu_studio' }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2220,7 +2224,7 @@ describe('FileManagerPage', () => {
     it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
       mockAuthUser([]);
       server.use(
-        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+        ...settingsHandlers({ use_slicer_api: true }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2238,7 +2242,7 @@ describe('FileManagerPage', () => {
     it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
       mockAuthUser(['library:upload']);
       server.use(
-        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+        ...settingsHandlers({ use_slicer_api: true }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -2319,7 +2323,7 @@ describe('FileManagerPage', () => {
 
     it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
       server.use(
-        http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
+        ...settingsHandlers({ use_slicer_api: true }),
       );
       const user = userEvent.setup();
       render(<FileManagerPage />);

+ 6 - 0
frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx

@@ -24,6 +24,12 @@ import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { SpoolBuddyAmsPage } from '../../pages/spoolbuddy/SpoolBuddyAmsPage';
 
+// The kiosk runs signed in; these tests render it without the auth provider.
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => ({ hasPermission: () => true }) };
+});
+
 // Capture every props payload that AssignSpoolModal receives so tests can
 // inspect spoolmanEnabled threading. Render nothing — we don't need to render
 // the modal contents; we only verify the parent wires the prop correctly.

+ 6 - 0
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -15,6 +15,12 @@ import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
 import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
 import { ToastProvider } from '../../contexts/ToastContext';
 
+// The kiosk runs signed in; these tests render it without the auth provider.
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => ({ hasPermission: () => true }) };
+});
+
 const mockShowToast = vi.fn();
 vi.mock('../../contexts/ToastContext', async (importOriginal) => {
   const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();

+ 6 - 0
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -17,6 +17,12 @@ import { ToastProvider } from '../../contexts/ToastContext';
 import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
 import { api as mockedApi, spoolbuddyApi as mockedSpoolbuddyApi } from '../../api/client';
 
+// The kiosk runs signed in; these tests render it without the auth provider.
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => ({ hasPermission: () => true }) };
+});
+
 // Mock the API modules. spoolman-* mocks default to "spoolman disabled" so
 // existing tests run in internal-inventory mode — the new spoolman parity
 // test below overrides getSpoolmanSettings before rendering.

+ 12 - 7
frontend/src/api/client.ts

@@ -1612,6 +1612,11 @@ export interface AppSettings {
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 
+// Settings as a page sees them: everything from GET /settings for a user who
+// may read it, otherwise the subset /settings/ui-preferences and
+// /settings/ui-flags serve, which reports G-code snippets only as set or not.
+export type UiSettings = Partial<AppSettings> & { has_gcode_snippets?: boolean };
+
 // MQTT relay status
 export interface MQTTStatus {
   enabled: boolean;
@@ -6180,13 +6185,13 @@ export const api = {
   // non-admin reading GET /settings gets a 403 and every gate that consults it
   // silently takes its fallback (#3023). Keep in sync with _UI_FLAG_FIELDS in
   // backend/app/api/routes/settings.py.
-  getUiFlags: () =>
-    request<{
-      billing_enabled?: boolean;
-      user_notifications_enabled?: boolean;
-      currency?: string;
-      check_updates?: boolean;
-    }>('/settings/ui-flags'),
+  getUiFlags: () => request<UiSettings>('/settings/ui-flags'),
+  // The two above together: what a page needs from the settings when its user
+  // may not read GET /settings. See useUiSettings.
+  getUiSettings: async (): Promise<UiSettings> => {
+    const [preferences, flags] = await Promise.all([api.getUiPreferences(), api.getUiFlags()]);
+    return { ...(preferences as UiSettings), ...flags };
+  },
   updateSettings: (data: AppSettingsUpdate) =>
     request<AppSettings>('/settings/', {
       method: 'PUT',

+ 2 - 4
frontend/src/components/AMSHistoryModal.tsx

@@ -15,6 +15,7 @@ import {
 import { api, type AMSHistoryResponse } from '../api/client';
 import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
 import { useTranslation } from 'react-i18next';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 interface AMSHistoryModalProps {
   isOpen: boolean;
@@ -55,10 +56,7 @@ export function AMSHistoryModal({
   const [timeRange, setTimeRange] = useState<TimeRange>('24h');
   const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 

+ 2 - 5
frontend/src/components/AssignSpoolModal.tsx

@@ -9,6 +9,7 @@ import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
 import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 interface AssignSpoolModalProps {
   isOpen: boolean;
@@ -87,11 +88,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     enabled: isOpen,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: () => api.getSettings(),
-    enabled: isOpen,
-  });
+  const { data: settings } = useUiSettings({ enabled: isOpen });
 
   const { data: spoolmanSpools, isLoading: spoolmanLoading } = useQuery({
     queryKey: ['spoolman-inventory-spools', 'assign-modal'],

+ 2 - 1
frontend/src/components/BatchOrdersView.tsx

@@ -9,6 +9,7 @@ import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { formatDuration, parseUTCDate } from '../utils/date';
 import { getCurrencySymbol } from '../utils/currency';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all';
 
@@ -35,7 +36,7 @@ export function BatchOrdersView({ hasPermission, t, focusBatchId = null }: Batch
   const [statusFilter, setStatusFilter] = useState<StatusFilter>(focusBatchId ? 'all' : 'active');
   const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(null);
 
-  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const { data: settings } = useUiSettings();
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 
   const { data: batches, isLoading } = useQuery({

+ 2 - 1
frontend/src/components/ForecastPanel.tsx

@@ -16,6 +16,7 @@ import type { InventorySpool, SpoolUsageRecord, FilamentSkuSettings, ShoppingLis
 import { getSwatchStyle } from '../utils/colors';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 
@@ -199,7 +200,7 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
   const [listOpen, setListOpen] = useState(false);
   const [chartDays, setChartDays] = useState<ChartDays>(30);
 
-  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, enabled: canRead });
+  const { data: settings } = useUiSettings({ enabled: canRead });
   const { data: skuSettingsList = [] } = useQuery({ queryKey: ['sku-settings'], queryFn: api.getSkuSettings, staleTime: 60_000, enabled: canRead });
   const { data: usageHistory = [] } = useQuery({ queryKey: ['all-usage-history-forecast'], queryFn: () => api.getAllUsageHistory(5000), staleTime: 60_000, enabled: canRead });
   const { data: shoppingList = [] } = useQuery({ queryKey: ['shopping-list'], queryFn: api.getShoppingList, staleTime: 30_000, enabled: canRead });

+ 2 - 4
frontend/src/components/HeaterHistoryModal.tsx

@@ -14,6 +14,7 @@ import {
 import { api, type HeaterSensorKind, type PrinterSensorHistoryResponse } from '../api/client';
 import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
 import { useTranslation } from 'react-i18next';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 interface HeaterHistoryModalProps {
   isOpen: boolean;
@@ -63,10 +64,7 @@ export function HeaterHistoryModal({
     setKind(initialKind);
   }, [initialKind]);
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 

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

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState } from 'react';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Battery, Droplets, RotateCcw, Save, Settings2, Thermometer, X } from 'lucide-react';
 import { api } from '../api/client';
@@ -25,6 +25,7 @@ import {
   type LocationSensorCategoryDefaults,
   type LocationSensorDefaults,
 } from '../utils/locationSensorDefaults';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 interface Props {
   onClose: () => void;
@@ -157,7 +158,7 @@ export function LocationSensorOptionsModal({ onClose }: Props) {
   const [optimalColor, setOptimalColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertOptimalColor());
   const [showResetConfirm, setShowResetConfirm] = useState(false);
 
-  const { data: appSettings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const { data: appSettings } = useUiSettings();
   const [pollInterval, setPollInterval] = useState(DEFAULT_POLL_INTERVAL);
 
   // Seed the server-backed fields once, and never over an edit in progress.
@@ -178,7 +179,8 @@ export function LocationSensorOptionsModal({ onClose }: Props) {
   useEffect(() => {
     if (!appSettings || seeded.current) return;
     seeded.current = true;
-    if (!touched.current.has('pollInterval')) setPollInterval(appSettings.location_sensor_poll_interval);
+    const pollFromServer = appSettings.location_sensor_poll_interval;
+    if (!touched.current.has('pollInterval') && pollFromServer != null) setPollInterval(pollFromServer);
     const fromServer = loadLocationSensorDefaults(appSettings.location_sensor_alert_defaults);
     setDefaults((prev) => {
       const next = { ...fromServer };

+ 3 - 2
frontend/src/components/ManyfoldTab.tsx

@@ -33,6 +33,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { isSliceableLibraryFile } from '../utils/libraryFiles';
 import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 const MANYFOLD_ERROR_CODES = new Set([
   'manyfold_not_configured',
@@ -404,7 +405,7 @@ function ManyfoldModelView({
     queryKey: ['manyfold-model', modelId],
     queryFn: () => api.getManyfoldModel(modelId),
   });
-  const settingsQuery = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings() });
+  const settingsQuery = useUiSettings();
   const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
   const desktopSlicer: SlicerType = resolveDesktopSlicer(
     settingsQuery.data?.open_in_slicer,
@@ -703,7 +704,7 @@ export function ManyfoldTab() {
     setPicked(new Map());
   };
 
-  const settingsQuery = useQuery({ queryKey: ['settings'], queryFn: () => api.getSettings() });
+  const settingsQuery = useUiSettings();
   const statusQuery = useQuery({ queryKey: ['manyfold-status'], queryFn: () => api.getManyfoldStatus() });
   const configured = statusQuery.data?.configured ?? false;
   const modelsQuery = useQuery({

+ 2 - 2
frontend/src/components/ModelViewerModal.tsx

@@ -1,6 +1,5 @@
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
-import { useQuery } from '@tanstack/react-query';
 import { ExternalLink, Box, Cog, Loader2, Layers, Check, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { Button } from './Button';
@@ -10,6 +9,7 @@ import { api, withMediaToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // The modal shows the model only; G-code has its own full-page viewer.
 type ViewTab = '3d';
@@ -144,7 +144,7 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: Sli
 export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy, onSnapshot }: ModelViewerModalProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
-  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const { data: settings } = useUiSettings();
   // Desktop "Open in Slicer" target — falls back to preferred_slicer when the
   // user hasn't explicitly chosen a different desktop slicer (#1329). This
   // variable is only used for URI-handoff; sidecar slicing keeps using

+ 2 - 4
frontend/src/components/NotificationLogViewer.tsx

@@ -7,6 +7,7 @@ import { parseUTCDate, formatTimeOnly, formatDateTime, type TimeFormat } from '.
 import type { NotificationLogEntry } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 const EVENT_COLORS: Record<string, string> = {
   print_start: 'text-blue-700 dark:text-blue-400',
@@ -33,10 +34,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
   const [expandedId, setExpandedId] = useState<number | null>(null);
   const [showFailedOnly, setShowFailedOnly] = useState(false);
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 

+ 2 - 4
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -12,6 +12,7 @@ import { displayHex } from './displayHex';
 import { useFilamentLabels } from './useFilamentLabels';
 import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
 import type { FilamentMappingProps, RackGroupInfo } from './types';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 /**
  * Filament mapping UI for comparing required filaments with loaded AMS slots.
@@ -106,10 +107,7 @@ export function FilamentMapping({
   // Settings + inventory map drive the same prefer-lowest + AMS-backup gate
   // the dispatcher uses (#1766). Without this, the per-slot dropdown's
   // auto-suggestion could disagree with what actually gets dispatched.
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
   const { data: inventoryRemain } = useQuery({
     queryKey: ['printer-inventory-remain', printerId],
     queryFn: () => api.getInventoryRemain(printerId),

+ 3 - 2
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -25,6 +25,7 @@ import {
 import type { PrinterSelectorProps, AssignmentMode } from './types';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   /** Per-printer mapping results (only used when multiple printers selected) */
@@ -67,6 +68,7 @@ function InlineMappingEditor({
   onUpdateConfig: (config: Partial<PerPrinterConfig>) => void;
 }) {
   const queryClient = useQueryClient();
+  const { data: uiSettings } = useUiSettings();
   const [isRefreshing, setIsRefreshing] = useState(false);
 
   const handleSlotChange = (slotId: number, value: string) => {
@@ -110,12 +112,11 @@ function InlineMappingEditor({
       isManual = true;
     } else {
       const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
-      const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
       loaded = autoMatchFilament(
         req,
         printerResult.loadedFilaments,
         usedTrayIds,
-        effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
+        effectivePreferLowest(uiSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
         printerResult.inventoryByTrayId,
       ) as LoadedFilament | undefined;
     }

+ 5 - 7
frontend/src/components/PrintModal/index.tsx

@@ -41,6 +41,7 @@ import type {
 } from './types';
 import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
 import { NumberInput } from '../NumberInput';
+import { hasGcodeSnippets, useUiSettings } from '../../hooks/useUiSettings';
 
 /** Same filament: type ignoring case, colour as RRGGBB ignoring `#`, case and alpha. */
 function isSameFilament(a: { type: string; color: string }, b: { type: string; color: string }): boolean {
@@ -321,10 +322,7 @@ export function PrintModal({
   const effectivePrinterId = selectedPrinters.length > 0 ? selectedPrinters[0] : null;
 
   // Queries
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   // Sync print option defaults from settings once available
   const printDefaultsApplied = useRef(false);
@@ -1630,10 +1628,10 @@ export function PrintModal({
   // queue item regardless of batch size, so there's no underlying reason for
   // the quantity-1 case to be blocked.
   useEffect(() => {
-    if (mode === 'create' && scheduleOptions.gcodeInjection && !settings?.gcode_snippets) {
+    if (mode === 'create' && scheduleOptions.gcodeInjection && !hasGcodeSnippets(settings)) {
       setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false }));
     }
-  }, [mode, settings?.gcode_snippets, scheduleOptions.gcodeInjection]);
+  }, [mode, settings, scheduleOptions.gcodeInjection]);
 
   // Modal title and action button text based on mode
   const getModalConfig = () => {
@@ -2070,7 +2068,7 @@ export function PrintModal({
               canControlPrinter={hasPermission('printers:control')}
               showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
               printerCount={selectedPrinters.length}
-              hasGcodeSnippets={!!settings?.gcode_snippets}
+              hasGcodeSnippets={hasGcodeSnippets(settings)}
               needsReview={needsReview}
             />
 

+ 2 - 4
frontend/src/components/RunWithPipelineModal.tsx

@@ -10,6 +10,7 @@ import {
 } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // Same source-kind shape SliceModal uses, so the same library-file vs archive
 // distinction flows through eligibility-check, run dispatch, AND the progress
@@ -47,10 +48,7 @@ export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalPr
   });
   // Cap from settings (PR C). Falls back to 50 when the fetch is in-flight or
   // missing — same default the backend writes.
-  const { data: settings } = useQuery({
-    queryKey: ['app-settings'],
-    queryFn: () => api.getSettings(),
-  });
+  const { data: settings } = useUiSettings();
   const maxCopies = settings?.pipeline_max_copies ?? 50;
 
   const sourceRef = { kind: source.kind, id: source.id } as const;

+ 2 - 5
frontend/src/components/SpoolFormModal.tsx

@@ -31,6 +31,7 @@ import {
   invalidateInventoryLocations,
   invalidateSpoolAndLocationQueries,
 } from '../utils/inventoryQueries';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 type TabId = 'filament' | 'appearance' | 'printers';
 
@@ -772,11 +773,7 @@ export function SpoolFormModal({
     queryFn: () => api.getSpools(true),
     enabled: isOpen,
   });
-  const { data: settingsForForm } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-    enabled: isOpen,
-  });
+  const { data: settingsForForm } = useUiSettings({ enabled: isOpen });
 
   // Backend Bambu printer-model registry, so the Printers tab can read the
   // model out of a preset name and offer each model only its own presets. The

+ 2 - 6
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -9,6 +9,7 @@ import type { AmsThresholds } from './AmsUnitCard';
 import { getFillBarColor } from '../../utils/amsHelpers';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { spoolSwatchStyle } from './spoolPaint';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -205,12 +206,7 @@ export function AssignToAmsModal({
     enabled: isOpen && targetPrinterId !== null,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: () => api.getSettings(),
-    enabled: isOpen,
-    staleTime: 5 * 60 * 1000,
-  });
+  const { data: settings } = useUiSettings({ enabled: isOpen, staleTime: 5 * 60 * 1000 });
 
   const { data: assignments } = useQuery({
     queryKey: ['spool-assignments', targetPrinterId],

+ 2 - 4
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -12,6 +12,7 @@ import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/
 import { VirtualKeyboard } from '../VirtualKeyboard';
 import { PageLoading } from '../PageLoading';
 import { useToast } from '../../contexts/ToastContext';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 export function SpoolBuddyLayout() {
   // Cascade a re-render into all SpoolBuddy pages when the color catalog
@@ -35,10 +36,7 @@ export function SpoolBuddyLayout() {
   }, [setViewportSuppressed]);
 
   // Sync language from backend settings (kiosk has its own browser with empty localStorage)
-  const { data: appSettings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: appSettings } = useUiSettings();
   useEffect(() => {
     if (appSettings?.language && appSettings.language !== i18n.language) {
       i18n.changeLanguage(appSettings.language);

+ 2 - 4
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { WifiOff } from 'lucide-react';
 import { api, type Printer } from '../../api/client';
 import { formatTimeOnly } from '../../utils/date';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 interface SpoolBuddyTopBarProps {
   selectedPrinterId: number | null;
@@ -41,10 +42,7 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     }
   }, [onlinePrinters, selectedPrinterId, onPrinterChange]);
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   // Clock - update every second for kiosk display
   useEffect(() => {

+ 5 - 3
frontend/src/contexts/ThemeContext.tsx

@@ -42,7 +42,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
   // and returns 401 — harmless (the catch swallows it) but noisy in the
   // network panel and wasteful. ThemeProvider is mounted inside
   // AuthProvider in App.tsx specifically so this hook is callable.
-  const { authEnabled, user, loading: authLoading } = useAuth();
+  const { authEnabled, user, loading: authLoading, hasPermission } = useAuth();
+  const readsAllSettings = hasPermission('settings:read');
 
   // Mode
   const [mode, setModeState] = useState<ThemeMode>(() => {
@@ -108,7 +109,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
   useEffect(() => {
     if (authLoading) return;
     if (authEnabled && user === null) return;
-    api.getSettings().then((settings) => {
+    // The theme is part of what any signed-in user may read (useUiSettings).
+    (readsAllSettings ? api.getSettings() : api.getUiSettings()).then((settings) => {
       // Dark settings
       if (settings.dark_style) {
         setDarkStyleState(settings.dark_style as ThemeStyle);
@@ -139,7 +141,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
     // Re-fetch when auth state transitions (e.g. login completes); the
     // gate above short-circuits subsequent calls once we already have a
     // valid sync.
-  }, [authLoading, authEnabled, user]);
+  }, [authLoading, authEnabled, user, readsAllSettings]);
 
   // Apply theme classes based on current mode
   useEffect(() => {

+ 26 - 0
frontend/src/hooks/useUiSettings.ts

@@ -0,0 +1,26 @@
+import { useQuery } from '@tanstack/react-query';
+import { api, type UiSettings } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+
+/**
+ * The install settings a page needs to render, for any signed-in user.
+ *
+ * GET /settings needs settings:read, and a user without it got a 403 that left
+ * every value at its fallback. Such a user reads the subset served without
+ * that permission instead; everyone else keeps the full settings, under the
+ * same query key as before.
+ */
+export function useUiSettings(options: { enabled?: boolean; staleTime?: number } = {}) {
+  const { hasPermission } = useAuth();
+  const readsAll = hasPermission('settings:read');
+  return useQuery<UiSettings>({
+    queryKey: readsAll ? ['settings'] : ['ui-settings'],
+    queryFn: readsAll ? () => api.getSettings() : () => api.getUiSettings(),
+    ...options,
+  });
+}
+
+/** Whether G-code snippets are set up, from either form of the settings. */
+export function hasGcodeSnippets(settings: UiSettings | undefined): boolean {
+  return !!(settings?.gcode_snippets || settings?.has_gcode_snippets);
+}

+ 3 - 2
frontend/src/hooks/useUnknownTagPrompt.ts

@@ -6,6 +6,7 @@ import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useTranslation } from 'react-i18next';
 import { getAmsLabel } from '../utils/amsHelpers';
+import { useUiSettings } from './useUiSettings';
 
 export interface UnknownTagDetail {
   printer_id: number;
@@ -46,6 +47,7 @@ export function useUnknownTagPrompt() {
   const [queue, setQueue] = useState<UnknownSpoolPrompt[]>([]);
 
   const isAuthed = !authEnabled || !!user;
+  const { data: uiSettings } = useUiSettings({ enabled: isAuthed });
 
   const buildPrompt = useCallback(
     (detail: UnknownTagDetail): UnknownSpoolPrompt | null => {
@@ -98,8 +100,7 @@ export function useUnknownTagPrompt() {
 
   const addMutation = useMutation({
     mutationFn: async (prompt: UnknownSpoolPrompt) => {
-      const settings = queryClient.getQueryData<{ spoolman_enabled?: boolean }>(['settings']);
-      if (settings?.spoolman_enabled) {
+      if (uiSettings?.spoolman_enabled) {
         await api.createSpoolmanSpoolFromSlot({
           printer_id: prompt.printer_id,
           ams_id: prompt.ams_id,

+ 2 - 4
frontend/src/pages/ArchivesPage.tsx

@@ -105,6 +105,7 @@ import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatFileSize } from '../utils/file';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
@@ -3192,10 +3193,7 @@ export function ArchivesPage() {
     queryFn: () => api.getProjects(),
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   // Print Log user filter -- names only, so the slim listing is enough (#1894).
   const { data: users } = useQuery({

+ 2 - 5
frontend/src/pages/FileManagerPage.tsx

@@ -64,7 +64,6 @@ import type {
   LibraryFolderCreate,
   LibraryFolderUpdate,
   ExternalFolderCreate,
-  AppSettings,
   Archive,
   Permission,
 } from '../api/client';
@@ -90,6 +89,7 @@ import { formatFileSize } from '../utils/file';
 import { assignableProjects } from '../utils/projectTree';
 import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { isSlicedLibraryFile, isSliceableLibraryFile } from '../utils/libraryFiles';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -1746,10 +1746,7 @@ export function FileManagerPage() {
   }, [searchParams]);
 
   // Queries
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: () => api.getSettings() as Promise<AppSettings>,
-  });
+  const { data: settings } = useUiSettings();
 
   const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
 

+ 3 - 5
frontend/src/pages/InventoryPage.tsx

@@ -43,6 +43,7 @@ import {
   useLocationSensorColorPrefs,
   type LocationSensorAlertColor,
 } from '../utils/locationSensorDefaults';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 type ArchiveFilter = 'active' | 'archived';
 type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
@@ -735,10 +736,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     return 15;
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const dateFormat: DateFormat = settings?.date_format || 'system';
   const locationSensorPollIntervalMs = (settings?.location_sensor_poll_interval || 120) * 1000;
@@ -2963,7 +2961,7 @@ function SpoolLocationFooter({
 }) {
   const { t } = useTranslation();
 
-  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const { data: settings } = useUiSettings();
   const pollIntervalMs = (settings?.location_sensor_poll_interval || 120) * 1000;
 
   // Same query key as the list fetch in InventoryPage's body, so this is a

+ 2 - 4
frontend/src/pages/MakerworldPage.tsx

@@ -19,6 +19,7 @@ import { SliceModal, type SliceSource } from '../components/SliceModal';
 import { Cog } from 'lucide-react';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // MakerWorld's API payloads are passed through as opaque dicts; these helpers
 // pull known fields out in a type-safe way so a missing/renamed field shows
@@ -173,10 +174,7 @@ export function MakerworldPage({ embedded = false }: { embedded?: boolean } = {}
     queryFn: () => api.getMakerworldRecentImports(10),
   });
 
-  const settingsQuery = useQuery({
-    queryKey: ['settings'],
-    queryFn: () => api.getSettings(),
-  });
+  const settingsQuery = useUiSettings();
   // MakerWorld plates are unsliced project files — they can't be sent
   // directly to a printer. The "slice in slicer" action below imports the
   // 3MF and hands it to the user's configured slicer; from there the

+ 2 - 5
frontend/src/pages/NotificationsPage.tsx

@@ -8,6 +8,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 export function NotificationsPage() {
   const { t } = useTranslation();
@@ -29,11 +30,7 @@ export function NotificationsPage() {
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
-  const { data: settings, isLoading: isSettingsLoading } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-    staleTime: 5 * 60 * 1000,
-  });
+  const { data: settings, isLoading: isSettingsLoading } = useUiSettings({ staleTime: 5 * 60 * 1000 });
 
   // Fetch current preferences
   const { data: preferences, isLoading } = useQuery({

+ 2 - 1
frontend/src/pages/PrintersPage.tsx

@@ -205,6 +205,7 @@ import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/Co
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { NumberInput } from '../components/NumberInput';
 import { getCurrencySymbol } from '../utils/currency';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // The status filter's options, and the only values it may hold. One list so a
 // saved filter cannot be validated against a set the dropdown has since moved
@@ -8479,7 +8480,7 @@ function EditPrinterModal({
     is_active: printer.is_active,
     wear_cost_per_hour: printer.wear_cost_per_hour ? String(printer.wear_cost_per_hour) : '',
   });
-  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const { data: settings } = useUiSettings();
   const currency = getCurrencySymbol(settings?.currency || 'USD');
 
   // Groups can be given a location (#1727), so a move changes who can use the

+ 2 - 4
frontend/src/pages/ProjectDetailPage.tsx

@@ -49,6 +49,7 @@ import { ProjectModal } from './ProjectsPage';
 import { getCurrencySymbol } from '../utils/currency';
 import { isSlicedLibraryFile } from '../utils/libraryFiles';
 import { NumberInput } from '../components/NumberInput';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 function formatFilament(grams: number): string {
   if (grams >= 1000) {
@@ -233,10 +234,7 @@ export function ProjectDetailPage() {
     enabled: projectId > 0,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const { data: linkedFolders } = useQuery({
     queryKey: ['project-folders', projectId],

+ 2 - 4
frontend/src/pages/ProjectsPage.tsx

@@ -34,6 +34,7 @@ import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { getCurrencySymbol } from '../utils/currency';
 import { eligibleParents } from '../utils/projectTree';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 const PROJECT_COLORS = [
   '#ef4444', // red
@@ -952,10 +953,7 @@ export function ProjectsPage() {
     localStorage.setItem('projects-collapse-subprojects', String(next));
   };
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
 

+ 4 - 5
frontend/src/pages/QueuePage.tsx

@@ -85,6 +85,7 @@ import { QueueTimelineView } from '../components/QueueTimelineView';
 import { compareQueueOrder } from '../utils/queueOrder';
 import { BatchOrdersView } from '../components/BatchOrdersView';
 import { buildLoadedFilaments, type LoadedFilament } from '../hooks/useFilamentMapping';
+import { hasGcodeSnippets, useUiSettings } from '../hooks/useUiSettings';
 
 type QueueFilamentDisplay = {
   slotId: number;
@@ -1844,10 +1845,7 @@ export function QueuePage() {
     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
   );
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
@@ -1877,6 +1875,7 @@ export function QueuePage() {
     mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['ui-settings'] });
     },
   });
 
@@ -3289,7 +3288,7 @@ export function QueuePage() {
           onClose={() => setShowBulkEditModal(false)}
           isSaving={bulkUpdateMutation.isPending}
           canControlPrinter={hasPermission('printers:control')}
-          hasGcodeSnippets={!!settings?.gcode_snippets}
+          hasGcodeSnippets={hasGcodeSnippets(settings)}
           t={t}
         />
       )}

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

@@ -1188,6 +1188,7 @@ export function SettingsPage() {
       // cannot read /settings. Nothing invalidated it, so a currency change
       // sat behind that query's own staleTime instead of showing up (#3123).
       queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
+      queryClient.invalidateQueries({ queryKey: ['ui-settings'] });
       // Switching announcements off (or to all users) changes who sees the
       // sidebar entry and the banner; don't wait for the next poll.
       queryClient.invalidateQueries({ queryKey: ['announcements'] });

+ 2 - 4
frontend/src/pages/StatsPage.tsx

@@ -46,6 +46,7 @@ import { getCurrencySymbol } from '../utils/currency';
 import { formatWeight } from '../utils/weight';
 import { parseUTCDate, formatDuration } from '../utils/date';
 import { MetricToggle, type Metric } from '../components/MetricToggle';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 // Timeframe types and helpers
 type TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom';
@@ -1077,10 +1078,7 @@ export function StatsPage() {
     queryFn: () => api.getArchivesSlim(effectiveDateRange.dateFrom, effectiveDateRange.dateTo, createdByIdParam),
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   // The supplier widget aggregates the internal spool table, which is empty
   // in Spoolman mode — there the assignments live in the Spoolman twin table.

+ 2 - 4
frontend/src/pages/SystemInfoPage.tsx

@@ -31,6 +31,7 @@ import { LogViewer } from '../components/LogViewer';
 import { ConnectionDiagnosticModal } from '../components/ConnectionDiagnostic';
 import { SystemHealthPanel } from '../components/SystemHealthPanel';
 import { formatDateTime, type TimeFormat } from '../utils/date';
+import { useUiSettings } from '../hooks/useUiSettings';
 
 function formatBytes(bytes: number): string {
   if (bytes < 1024) return `${bytes} B`;
@@ -121,10 +122,7 @@ export function SystemInfoPage() {
     refetchInterval: 10 * 1000,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const { data: libraryStats } = useQuery({
     queryKey: ['library-stats'],

+ 2 - 5
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -33,6 +33,7 @@ import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
 import { AssignSpoolModal } from '../../components/AssignSpoolModal';
 import { LinkSpoolModal } from '../../components/LinkSpoolModal';
 import { useToast } from '../../contexts/ToastContext';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 function getAmsName(amsId: number): string {
   if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
@@ -76,11 +77,7 @@ export function SpoolBuddyAmsPage() {
     staleTime: 2 * 60 * 1000,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: () => api.getSettings(),
-    staleTime: 5 * 60 * 1000,
-  });
+  const { data: settings } = useUiSettings({ staleTime: 5 * 60 * 1000 });
 
   // Fetch Spoolman status to enable fill-level chain
   const { data: spoolmanStatus } = useQuery({

+ 3 - 8
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -54,6 +54,7 @@ import {
 } from '../../components/spool-form/utils';
 import { MATERIALS } from '../../components/spool-form/constants';
 import { NumberInput } from '../../components/NumberInput';
+import { useUiSettings } from '../../hooks/useUiSettings';
 
 type Tab = 'existing' | 'new' | 'replace';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
@@ -108,10 +109,7 @@ export function SpoolBuddyWriteTagPage() {
     refetchInterval: 5000,
   });
 
-  const { data: settings } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settings } = useUiSettings();
 
   const device = devices[0];
   const deviceOnline = sbState.deviceOnline;
@@ -463,10 +461,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
     queryFn: () =>
       spoolmanMode ? api.getSpoolmanInventorySpools(true) : api.getSpools(true),
   });
-  const { data: settingsForForm } = useQuery({
-    queryKey: ['settings'],
-    queryFn: api.getSettings,
-  });
+  const { data: settingsForForm } = useUiSettings();
 
   const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
   const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AdditionalSection-DARQM-bp.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AmsUnitCard-B7uKwUi9.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ArchivesPage-B7e49n2x.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ArchivesPage-Cc-Afs6N.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AreaChart-CwTUicZX.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AssignToAmsModal-CdZZwdGB.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AssignToAmsModal-DxMRLw9M.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/AuthContext-C84ko0T4.js


+ 1 - 1
static/assets/Button-B2stjvVl.js → static/assets/Button-CPK1NMqY.js

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CamWallPage-CT2wYxz_.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CamWallPage-DLMO7KTj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraDiagnoseModal-DFSm4iqj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraDiagnoseModal-DzNRLl7L.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraPage-BHoSMv8h.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraPage-DcsCiwvU.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CameraWall-CjEnvIIp.js


+ 1 - 1
static/assets/Card-BvtE18YT.js → static/assets/Card-DnEKZXBw.js

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CartesianChart-DzNodXkf.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ColumnConfigModal-CS88vSHi.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConfigureAmsSlotModal-BfjQbqLi.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConfigureAmsSlotModal-oO1BYxjJ.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConnectAuthorizePage--tchC0OA.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ConnectAuthorizePage-C9MyxLc0.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ContextMenu-C5OLyWWj.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/CopyButton-DaEvAsbn.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ErrorBarContext-DRYme72x.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ExternalLinkPage-DOH3XsR2.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/FileManagerPage-B6moaOZC.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
static/assets/FileManagerPage-D2IkrJLB.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FileUploadModal-BxaCrz7n.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FinancePage-76NLYr_U.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/FinancePage-BUiQzjji.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GCodeViewerPage-DygIdlIX.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GcodeToolpathViewer-Dx9neFgR.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/GroupEditPage-85AagC41.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ImagePreviewModal-n_Jj9jPx.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/InventoryPage-BTq0yWoy.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/InventoryPage-DBPc_AXQ.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LibraryTrashPage-DZqGDPJ2.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LibraryTrashPage-DjKLrLls.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LoginPage-Czd50HzW.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/LoginPage-DuTCvgew.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/MaintenancePage-DnqHbNcD.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/MaintenancePage-fMhO4x0n.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ModelSourcesPage-CaaXYLac.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/ModelSourcesPage-DNijFRF-.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/NotificationsPage-Cq-P-397.js


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/NotificationsPage-DoioRbHA.js


+ 1 - 1
static/assets/OverlayFrame-37LW4HkM.js → static/assets/OverlayFrame-DO4-H9rs.js

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

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/PdfPreviewModal-B6sVcYE5.js


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
static/assets/PdfPreviewModal-DnyjqrUQ.js


Неке датотеке нису приказане због велике количине промена