Browse Source

fix(settings): deleted API key stays on screen until manual reload

  The delete-API-key mutation called queryClient.invalidateQueries on the
  ['api-keys'] cache, which in react-query v5 should also refetch active
  queries — but in practice the deleted row remained visible until the
  user reloaded the page. Switched onSuccess to setQueryData so the
  deleted key is filtered out of the cache synchronously the moment the
  API confirms; no refetch round-trip required, no invalidation→refetch
  race possible. Create-path is unchanged (invalidateQueries was working
  there).

  Pins the contract with a new SettingsPage test that mocks the GET/DELETE
  endpoints, clicks delete, confirms, and asserts the row is gone without
  any reload.
maziggy 1 month ago
parent
commit
3e18d42613

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


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

@@ -422,4 +422,72 @@ describe('SettingsPage', () => {
       });
     });
   });
+
+  describe('API Keys tab — delete flow', () => {
+    // Without setQueryData on success the deleted row stayed visible until a
+    // manual reload — invalidateQueries didn't reliably trigger a UI swap on
+    // every browser. Pin the synchronous-removal contract here.
+    it('removes a deleted key from the list without a page reload', async () => {
+      const initialKeys = [
+        {
+          id: 42,
+          name: 'CI deploy key',
+          key_prefix: 'bk_abcd1234',
+          can_queue: true,
+          can_control_printer: false,
+          can_read_status: true,
+          printer_ids: null,
+          enabled: true,
+          last_used: null,
+          created_at: '2026-01-01T00:00:00Z',
+          expires_at: null,
+        },
+      ];
+
+      let deleteCallCount = 0;
+      server.use(
+        http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
+        http.delete('/api/v1/api-keys/:id', ({ params }) => {
+          deleteCallCount += 1;
+          expect(params.id).toBe('42');
+          return HttpResponse.json({ message: 'API key deleted' });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      // Switch to API Keys tab. Both desktop tab + mobile dropdown render
+      // the label, so just grab the button form.
+      await waitFor(() => {
+        expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
+      });
+      const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
+      expect(tabButton).toBeDefined();
+      await user.click(tabButton!);
+
+      // Key is listed
+      await waitFor(() => {
+        expect(screen.getByText('CI deploy key')).toBeInTheDocument();
+      });
+
+      // Click the trash button on the row
+      const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
+      expect(cards).not.toBeNull();
+      const trashButton = cards!.querySelectorAll('button');
+      await user.click(trashButton[trashButton.length - 1]);
+
+      // Confirm the deletion in the modal
+      const confirmButton = await screen.findByRole('button', { name: /delete/i });
+      await user.click(confirmButton);
+
+      // The deleted key disappears from the list immediately — no manual
+      // reload required. setQueryData drops it before any refetch could fire.
+      await waitFor(() => {
+        expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
+      });
+
+      expect(deleteCallCount).toBe(1);
+    });
+  });
 });

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

@@ -6,7 +6,7 @@ import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
-import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
+import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { CameraTokensSection } from './CameraTokensPage';
 import { Collapsible } from '../components/Collapsible';
@@ -402,8 +402,10 @@ export function SettingsPage() {
 
   const deleteAPIKeyMutation = useMutation({
     mutationFn: (id: number) => api.deleteAPIKey(id),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+    onSuccess: (_data, deletedId) => {
+      queryClient.setQueryData<APIKey[]>(['api-keys'], (old) =>
+        (old ?? []).filter((key) => key.id !== deletedId)
+      );
       showToast(t('settings.toast.apiKeyDeleted'));
     },
     onError: (error: Error) => {

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

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