Browse Source

feat(auth): permission-delegated Settings + Group editor routes; fix group-edit cache stale-read (#1083)

  Three intertwined changes, split by intent:

  1. Swap AdminRoute for PermissionRoute on /settings, /groups/new, and
     /groups/:id/edit. Admins retain full access; non-admin users whose
     group holds settings:read / groups:create / groups:update can now
     enter the respective pages instead of being silently redirected to
     the dashboard. SettingsPage's individual tabs and cards keep their
     existing per-action permission checks, so tabs a delegated user can't
     use stay hidden or disabled. AdminRoute had no other callers and is
     removed.

  2. Fix #1083: editing a custom group's permissions appeared to revert
     on reopen. The backend PATCH was persisting correctly — four new
     integration tests in test_groups_api.py (including a direct DB read
     after PATCH) confirm persistence, empty-list clear, preserve-on-
     absent, and 400 on bogus permission. The actual bug was a stale
     ['group', id] React Query cache: onSuccess invalidated ['groups']
     but not the detail key, so the 60s global staleTime served the pre-
     update body on re-mount. onSuccess now primes ['group', id] with the
     PATCH response body (invalidation is not enough — it races with the
     refetch). Frontend regression test added.

  3. Delegated users with settings:read but not settings:update no longer
     get an infinite loop of failed-save toasts on Settings. The debounced
     auto-save effect fires PATCH /settings whenever localSettings diverges
     from the server snapshot; without a permission gate this produced an
     endless 403 → toast → re-render → effect → 403 loop. Three gates now:
     the updateSetting callback short-circuits with a single toast before
     localSettings diverges, the effect safety-nets the same check in case
     any call site bypasses updateSetting, and the language <select> (the
     only direct api.updateSettings bypass in the file) now routes through
     updateMutation with the same guard. New settings.toast.noPermissionUpdate
     key translated in all 8 locales.

  Scoping note: an earlier iteration of change #3 included a
  localSettings rollback inside updateMutation.onError — removed in
  review because it would have discarded in-progress admin typing on
  any transient network/server error. The three up-front guards make
  the rollback unnecessary for the permission case (mutation never
  fires), and preserving typed-in values on transient failures is the
  right call for admins.
maziggy 1 month ago
parent
commit
cecdf8f5a7

+ 5 - 0
CHANGELOG.md

@@ -4,7 +4,12 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.4b1] - Unreleased
 
+### Changed
+- **Settings page: permission-gated instead of admin-only** — the Settings sidebar entry has always been visible to any user holding `settings:read`, but the route guard required admin role, so a non-admin with `settings:read` would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with `settings:read` can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (`users:read`, `groups:update`, `oidc:*`, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (`groups:create` for `/groups/new`, `groups:update` for `/groups/:id/edit`), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.
+
 ### Fixed
+- **Settings: failed-save toast looped forever when the user lacked `settings:update`** — the Settings page runs a debounced auto-save effect that fires `PATCH /settings` whenever `localSettings` diverges from the last server snapshot. When a delegated user with `settings:read` but not `settings:update` toggled a control, the effect fired `PATCH`, got `403`, and kept re-firing every ~500 ms producing an endless stream of identical "Failed to save" toasts. Gated at three points so the mutation is never attempted without permission: (1) the `updateSetting` callback — every onChange path — shows one `settings.toast.noPermissionUpdate` toast and short-circuits before diverging `localSettings`; (2) the debounced-save effect safety-nets the same check in case any call site bypassed `updateSetting`; (3) the language `<select>` was a fire-and-forget direct `api.updateSettings` call that always flashed a success toast regardless of outcome — it now goes through `updateMutation` with the same permission guard. New `settings.toast.noPermissionUpdate` key added across all 8 locales with full translations (not English-fallback).
+- **Groups: edits to custom-group permissions appeared lost on reopen** ([#1083](https://github.com/maziggy/bambuddy/issues/1083)) — creating a custom group and reopening the editor showed the correct permissions, but after editing that group's permissions and saving, reopening the editor within ~1 minute displayed the pre-edit snapshot as if the save had failed. The backend `PATCH /api/v1/groups/{id}` was persisting correctly (now covered by four new integration tests in `test_groups_api.py`, including a direct DB read after update); the issue was purely in the frontend React Query cache — `GroupEditPage.onSuccess` invalidated `['groups']` (the list) but left the `['group', id]` detail cache stale, and with the app-wide 60 s `staleTime` the next mount served the cached pre-update body instead of refetching. `onSuccess` now primes the `['group', id]` detail cache with the `PATCH` response body so the next mount hits fresh data immediately without a round-trip. Create-path invalidates `['group']` for symmetry. Regression test in `GroupEditPage.test.tsx` verifies the detail cache contains the updated permissions after save.
 - **Setup: re-enabling auth could 422 on a password the form no longer needs** — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends `admin_password` in the body even though the backend route ignores it when an admin user already exists. The `SetupRequest` Pydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered `422 Value error, Password must contain at least one special character` before the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added in `test_auth_api.py`: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists.
 - **Queue: batch (quantity>1) double-dispatched onto the same printer** — scheduling an ASAP print with `quantity > 1` could end up with two queue items in `'printing'` status for the same printer, surfaced in the logs as `BUG: Multiple queue items in 'printing' status for printer N`. The scheduler's in-memory `busy_printers` set was seeded empty each tick and only populated after `_start_print` succeeded in the current iteration, so on the next tick (30 s later) `_is_printer_idle()` read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reported `IDLE` / `FINISH` — and dispatched the second batch item onto the already-running printer. `check_queue()` now queries `PrintQueueItem` for `status='printing'` rows and seeds `busy_printers` with their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered in `test_phantom_print_hardening.py` (`TestBusyPrinterSeedingFromPrintingItems`): seeding query returns printers with `'printing'` rows only, returns empty when none exist, and end-to-end `check_queue()` does not call `_start_print` for a pending item whose printer already has a `'printing'` row even when `_is_printer_idle()` is forced `True`.
 - **Queue: active-item progress bar flashed 100% before dropping to 0%** — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final `mc_percent` was) for the few seconds between dispatch and the printer's MQTT state transitioning to `RUNNING`. Frontend `QueuePage.tsx` read `status.progress` directly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated on `status.state` being `RUNNING` or `PAUSE`; in any other state (including `FINISH` from the prior print, `IDLE`, or `PREPARE` while heating) the bar renders at 0% with no stale ETA/layer values.

+ 134 - 0
backend/tests/integration/test_groups_api.py

@@ -0,0 +1,134 @@
+"""Integration tests for the /api/v1/groups/* endpoints.
+
+Issue #1083: updates to a group's permission list must persist across GET,
+regardless of whether the frontend invalidates its React Query cache.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.group import Group
+
+
+async def _setup_admin(async_client: AsyncClient) -> dict[str, str]:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "gadmin", "admin_password": "AdminPass1!"},
+    )
+    resp = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "gadmin", "password": "AdminPass1!"},
+    )
+    return {"Authorization": f"Bearer {resp.json()['access_token']}"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_update_group_permissions_persists(async_client: AsyncClient, db_session):
+    """PATCH /groups/{id} with a new permissions list must persist to DB (#1083)."""
+    headers = await _setup_admin(async_client)
+
+    create = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={
+            "name": "test_perms",
+            "permissions": ["printers:read", "archives:read", "queue:read", "inventory:read"],
+        },
+    )
+    assert create.status_code == 201
+    gid = create.json()["id"]
+
+    # Update to a wholly different set
+    update = await async_client.patch(
+        f"/api/v1/groups/{gid}",
+        headers=headers,
+        json={"permissions": ["users:read", "groups:read"]},
+    )
+    assert update.status_code == 200
+    assert sorted(update.json()["permissions"]) == ["groups:read", "users:read"]
+
+    # Re-read via API — must reflect the update, not the creation
+    got = await async_client.get(f"/api/v1/groups/{gid}", headers=headers)
+    assert got.status_code == 200
+    assert sorted(got.json()["permissions"]) == ["groups:read", "users:read"]
+
+    # Direct DB read — same expectation
+    result = await db_session.execute(select(Group).where(Group.id == gid))
+    assert sorted(result.scalar_one().permissions or []) == ["groups:read", "users:read"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_update_group_to_empty_permissions(async_client: AsyncClient, db_session):
+    """Clearing all permissions via PATCH must result in an empty list, not a no-op."""
+    headers = await _setup_admin(async_client)
+
+    create = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": "test_clear", "permissions": ["printers:read", "archives:read"]},
+    )
+    gid = create.json()["id"]
+
+    update = await async_client.patch(
+        f"/api/v1/groups/{gid}",
+        headers=headers,
+        json={"permissions": []},
+    )
+    assert update.status_code == 200
+    assert update.json()["permissions"] == []
+
+    got = await async_client.get(f"/api/v1/groups/{gid}", headers=headers)
+    assert got.json()["permissions"] == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_update_group_without_permissions_field_preserves_existing(async_client: AsyncClient, db_session):
+    """PATCH without a permissions field (None) must leave the existing list untouched."""
+    headers = await _setup_admin(async_client)
+
+    create = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": "test_preserve", "permissions": ["printers:read", "archives:read"]},
+    )
+    gid = create.json()["id"]
+
+    # Only update description
+    update = await async_client.patch(
+        f"/api/v1/groups/{gid}",
+        headers=headers,
+        json={"description": "updated"},
+    )
+    assert update.status_code == 200
+    assert sorted(update.json()["permissions"]) == ["archives:read", "printers:read"]
+    assert update.json()["description"] == "updated"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_update_group_invalid_permission_rejected(async_client: AsyncClient):
+    """Invalid permission strings yield 400 and do not persist."""
+    headers = await _setup_admin(async_client)
+
+    create = await async_client.post(
+        "/api/v1/groups/",
+        headers=headers,
+        json={"name": "test_bad", "permissions": ["printers:read"]},
+    )
+    gid = create.json()["id"]
+
+    update = await async_client.patch(
+        f"/api/v1/groups/{gid}",
+        headers=headers,
+        json={"permissions": ["printers:read", "bogus:permission"]},
+    )
+    assert update.status_code == 400
+    assert "Invalid permissions" in update.json()["detail"]
+
+    # Existing value unchanged
+    got = await async_client.get(f"/api/v1/groups/{gid}", headers=headers)
+    assert got.json()["permissions"] == ["printers:read"]

+ 12 - 9
frontend/src/App.tsx

@@ -102,25 +102,28 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
   return <>{children}</>;
 }
 
-function AdminRoute({ children }: { children: React.ReactNode }) {
-  const { authEnabled, loading, user, isAdmin } = useAuth();
+function PermissionRoute({ permission, children }: { permission: string; children: React.ReactNode }) {
+  // Permission-gated route: any user with the given permission can enter, not
+  // just admins. Individual components below this guard apply their own
+  // per-action permission checks. Used for pages where delegation is supported
+  // (e.g. settings:read grants read-only access to Settings; specific tabs
+  // require their own permissions like users:read, groups:update, etc.).
+  const { authEnabled, loading, user, hasPermission } = useAuth();
 
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
   }
 
-  // If auth is not enabled, allow access (backward compatibility)
+  // Auth disabled → open access (backward compatibility)
   if (!authEnabled) {
     return <>{children}</>;
   }
 
-  // If auth is enabled but no user, redirect to login
   if (!user) {
     return <Navigate to="/login" replace />;
   }
 
-  // If user is not admin, redirect to home
-  if (!isAdmin) {
+  if (!hasPermission(permission as Parameters<typeof hasPermission>[0])) {
     return <Navigate to="/" replace />;
   }
 
@@ -189,9 +192,9 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
-                  <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
-                  <Route path="groups/new" element={<AdminRoute><GroupEditPage /></AdminRoute>} />
-                  <Route path="groups/:id/edit" element={<AdminRoute><GroupEditPage /></AdminRoute>} />
+                  <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
+                  <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
+                  <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />

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

@@ -215,4 +215,69 @@ describe('GroupEditPage', () => {
       });
     });
   });
+
+  describe('cache invalidation after save (#1083)', () => {
+    it('primes the single-group detail cache with the update response body', async () => {
+      // Regression for #1083: before the fix, onSuccess only invalidated the
+      // ['groups'] list query. The ['group', id] detail cache stayed stale
+      // under the global 60s staleTime, so reopening the editor showed the
+      // pre-update snapshot. The fix invalidates the detail key AND primes the
+      // cache with the server response so a re-mount sees fresh data.
+      const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
+      const { MemoryRouter, Routes, Route } = await import('react-router-dom');
+      const { AuthProvider } = await import('../../contexts/AuthContext');
+      const { ToastProvider } = await import('../../contexts/ToastContext');
+      const { ThemeProvider } = await import('../../contexts/ThemeContext');
+      const { render: rtlRender } = await import('@testing-library/react');
+
+      const queryClient = new QueryClient({
+        defaultOptions: { queries: { staleTime: 60_000, retry: false } },
+      });
+      const user = userEvent.setup();
+
+      const wrapper = (
+        <QueryClientProvider client={queryClient}>
+          <ThemeProvider>
+            <ToastProvider>
+              <AuthProvider>
+                <MemoryRouter initialEntries={['/groups/2/edit']}>
+                  <Routes>
+                    <Route path="/groups/:id/edit" element={<GroupEditPage />} />
+                    <Route path="/settings" element={<div>Settings</div>} />
+                  </Routes>
+                </MemoryRouter>
+              </AuthProvider>
+            </ToastProvider>
+          </ThemeProvider>
+        </QueryClientProvider>
+      );
+      rtlRender(wrapper);
+
+      // Wait for the group to load
+      await waitFor(() => {
+        expect(screen.getByDisplayValue('Operators')).toBeInTheDocument();
+      });
+
+      // Change permissions then save
+      await waitFor(() => {
+        expect(screen.getByText('Read Archives')).toBeInTheDocument();
+      });
+      const archivesCheckbox = screen.getByText('Read Archives').closest('label')!.querySelector('input')!;
+      await user.click(archivesCheckbox);
+
+      await user.click(screen.getByText('Save'));
+
+      // Wait for navigation (redirect to /settings)
+      await waitFor(() => {
+        expect(screen.getByText('Settings')).toBeInTheDocument();
+      });
+
+      // After save, the detail cache must have been primed with the server
+      // response (mocked PATCH returns mockGroup + body). The next mount
+      // should read the cached body, not the stale pre-update payload.
+      const cached = queryClient.getQueryData(['group', '2']) as { permissions: string[] } | undefined;
+      expect(cached).toBeDefined();
+      expect(cached!.permissions).toContain('archives:read');
+    });
+  });
 });

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

@@ -1881,6 +1881,7 @@ export default {
       passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
       enterGroupName: 'Bitte geben Sie einen Gruppennamen ein',
       settingsSaved: 'Einstellungen gespeichert',
+      noPermissionUpdate: 'Sie haben keine Berechtigung, Einstellungen zu ändern',
       cameraSettingsSaved: 'Kamera-Einstellungen gespeichert',
       enterCameraUrl: 'Bitte geben Sie eine Kamera-URL ein',
       passwordChanged: 'Passwort erfolgreich geändert',

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

@@ -1884,6 +1884,7 @@ export default {
       passwordTooShort: 'Password must be at least 6 characters',
       enterGroupName: 'Please enter a group name',
       settingsSaved: 'Settings saved',
+      noPermissionUpdate: 'You do not have permission to change settings',
       cameraSettingsSaved: 'Camera settings saved',
       enterCameraUrl: 'Please enter a camera URL',
       passwordChanged: 'Password changed successfully',

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

@@ -1830,6 +1830,7 @@ export default {
       passwordTooShort: 'Minimum 6 caractères',
       enterGroupName: 'Entrez un nom de groupe',
       settingsSaved: 'Paramètres enregistrés',
+      noPermissionUpdate: "Vous n'avez pas l'autorisation de modifier les paramètres",
       cameraSettingsSaved: 'Réglages caméra enregistrés',
       enterCameraUrl: 'Entrez une URL caméra',
       passwordChanged: 'Mot de passe modifié',

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

@@ -1830,6 +1830,7 @@ export default {
       passwordTooShort: 'La password deve essere di almeno 6 caratteri',
       enterGroupName: 'Inserisci un nome gruppo',
       settingsSaved: 'Impostazioni salvate',
+      noPermissionUpdate: 'Non hai il permesso di modificare le impostazioni',
       cameraSettingsSaved: 'Impostazioni camera salvate',
       enterCameraUrl: 'Inserisci un URL camera',
       passwordChanged: 'Password cambiata con successo',

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

@@ -1855,6 +1855,7 @@ export default {
       passwordTooShort: 'パスワードは6文字以上必要です',
       enterGroupName: 'グループ名を入力',
       settingsSaved: '設定を保存しました',
+      noPermissionUpdate: '設定を変更する権限がありません',
       cameraSettingsSaved: 'カメラ設定を保存しました',
       enterCameraUrl: 'カメラURLを入力してください',
       passwordChanged: 'パスワードが正常に変更されました',

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

@@ -1830,6 +1830,7 @@ export default {
       passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
       enterGroupName: 'Por favor, insira um nome de grupo',
       settingsSaved: 'Configurações salvas',
+      noPermissionUpdate: 'Você não tem permissão para alterar as configurações',
       cameraSettingsSaved: 'Configurações da câmera salvas',
       enterCameraUrl: 'Por favor, insira a URL da câmera',
       passwordChanged: 'Senha alterada com sucesso',

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

@@ -1882,6 +1882,7 @@ export default {
       passwordTooShort: '密码至少需要 6 个字符',
       enterGroupName: '请输入组名称',
       settingsSaved: '设置已保存',
+      noPermissionUpdate: '您没有权限更改设置',
       cameraSettingsSaved: '摄像头设置已保存',
       enterCameraUrl: '请输入摄像头 URL',
       passwordChanged: '密码修改成功',

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

@@ -1882,6 +1882,7 @@ export default {
       passwordTooShort: '密碼至少需要 6 個字元',
       enterGroupName: '請輸入群組名稱',
       settingsSaved: '設定已儲存',
+      noPermissionUpdate: '您沒有權限變更設定',
       cameraSettingsSaved: '攝影機設定已儲存',
       enterCameraUrl: '請輸入攝影機 URL',
       passwordChanged: '密碼修改成功',

+ 13 - 1
frontend/src/pages/GroupEditPage.tsx

@@ -47,6 +47,7 @@ export function GroupEditPage() {
       api.createGroup(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
+      queryClient.invalidateQueries({ queryKey: ['group'] });
       showToast(t('groups.toast.created'));
       navigate('/settings?tab=users');
     },
@@ -58,8 +59,19 @@ export function GroupEditPage() {
   const updateMutation = useMutation({
     mutationFn: (data: { name?: string; description?: string; permissions: Permission[] }) =>
       api.updateGroup(Number(id), data),
-    onSuccess: () => {
+    onSuccess: (updatedGroup) => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
+      // Prime the single-group detail cache with the PATCH response body so
+      // reopening the editor within the 60s default staleTime shows the
+      // newly-saved permissions instead of the stale pre-update snapshot
+      // (#1083). setQueryData alone is enough — we intentionally do NOT also
+      // invalidate ['group', id] because that would trigger an immediate
+      // background refetch that could race with / overwrite this primed value
+      // in test environments where the GET handler is a static mock; in
+      // production the server's GET would match this payload anyway.
+      if (updatedGroup) {
+        queryClient.setQueryData(['group', id], updatedGroup);
+      }
       showToast(t('groups.toast.updated'));
       navigate('/settings?tab=users');
     },

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

@@ -805,6 +805,13 @@ export function SettingsPage() {
     },
     onError: (error: Error) => {
       showToast(`Failed to save: ${error.message}`, 'error');
+      // No localSettings rollback here — the existing comment above (see
+      // onSuccess) already flags that overwriting localSettings would discard
+      // in-progress user input (e.g. typing a hostname). The no-permission
+      // loop is already prevented by the up-front guards in updateSetting and
+      // in the debounced-save effect, so this onError path now only fires for
+      // genuine server/network failures where preserving typed-in values is
+      // the right call.
     },
     onSettled: () => {
       // Reset saving flag when mutation completes (success or error)
@@ -831,6 +838,14 @@ export function SettingsPage() {
       return;
     }
 
+    // Safety net: skip auto-save entirely when the user lacks settings:update.
+    // The actual user feedback (toast + revert) lives in updateSetting below,
+    // which runs once per click. Doing it here as well would fire on every
+    // React render since the debounced-save effect depends on non-stable refs.
+    if (authEnabled && !hasPermission('settings:update')) {
+      return;
+    }
+
     // Check if there are actual changes
     const hasChanges =
       settings.auto_archive !== localSettings.auto_archive ||
@@ -981,11 +996,18 @@ export function SettingsPage() {
         clearTimeout(saveTimeoutRef.current);
       }
     };
-  }, [localSettings, settings, updateMutation]);
+  }, [localSettings, settings, updateMutation, authEnabled, hasPermission, showToast, t]);
 
   const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
+    // Gate at the point of user interaction (not in the debounced-save effect —
+    // that runs on every render and would fire the toast repeatedly). One toast
+    // per attempt; no local state divergence for a read-only delegated user.
+    if (authEnabled && !hasPermission('settings:update')) {
+      showToast(t('settings.toast.noPermissionUpdate'), 'error');
+      return;
+    }
     setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);
-  }, []);
+  }, [authEnabled, hasPermission, showToast, t]);
 
   const handleTestExternalCamera = async (printerId: number, url: string, cameraType: string) => {
     if (!url) {
@@ -1333,7 +1355,18 @@ export function SettingsPage() {
                 <div className="relative">
                   <select
                     value={i18n.language}
-                    onChange={(e) => { i18n.changeLanguage(e.target.value); api.updateSettings({ language: e.target.value }); showToast(t('settings.toast.settingsSaved'), 'success'); }}
+                    onChange={(e) => {
+                      const newLang = e.target.value;
+                      // Block server persist if the user lacks settings:update —
+                      // without this guard the fire-and-forget api.updateSettings
+                      // call below would 403 silently while a success toast flashed.
+                      if (authEnabled && !hasPermission('settings:update')) {
+                        showToast(t('settings.toast.noPermissionUpdate'), 'error');
+                        return;
+                      }
+                      i18n.changeLanguage(newLang);
+                      updateMutation.mutate({ language: newLang });
+                    }}
                     className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
                   >
                     {availableLanguages.map((lang) => (

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B2bhLHKc.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-DE7p9KKE.js"></script>
+    <script type="module" crossorigin src="/assets/index-B2bhLHKc.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CiCRNaHx.css">
   </head>
   <body>

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