diff --git a/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx b/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx index 313225927..8467d5001 100644 --- a/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx +++ b/web/src/app/home/components/api-integration-dialog/ApiIntegrationPanel.tsx @@ -125,7 +125,9 @@ export default function ApiIntegrationPanel({ const response = (await backendClient.get('/api/v1/apikeys')) as { keys: ApiKey[]; }; - setApiKeys(response.keys || []); + setApiKeys( + (response.keys || []).filter((key) => key.status !== 'revoked'), + ); } catch (error) { toast.error(`Failed to load API keys: ${error}`); } finally { diff --git a/web/tests/e2e/api-key-revocation.spec.ts b/web/tests/e2e/api-key-revocation.spec.ts new file mode 100644 index 000000000..51a440b77 --- /dev/null +++ b/web/tests/e2e/api-key-revocation.spec.ts @@ -0,0 +1,126 @@ +import { expect, test, type Page } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +function apiKey(id: number, name: string, status: 'active' | 'revoked') { + return { + id, + uuid: `key-${id}`, + name, + description: '', + scopes: [], + status, + secret_available: false, + created_at: '2026-10-18T00:00:00Z', + }; +} + +async function installKeyMocks( + page: Page, + keys: ReturnType[], + deleteFails = false, +) { + await installLangBotApiMocks(page, { authenticated: true }); + await page.route(/\/api\/v1\/apikeys(?:\/\d+)?$/, async (route) => { + if (route.request().method() === 'DELETE') { + if (deleteFails) { + await route.fulfill({ + status: 500, + json: { code: 500, message: 'Could not revoke key' }, + }); + return; + } + const id = Number( + new URL(route.request().url()).pathname.split('/').pop(), + ); + keys.find((key) => key.id === id)!.status = 'revoked'; + await route.fulfill({ json: { code: 0, data: {} } }); + return; + } + // Revocation retains the audit record in every subsequent list response. + await route.fulfill({ json: { code: 0, data: { keys } } }); + }); + await page.route('**/api/v1/webhooks', (route) => + route.fulfill({ json: { code: 0, data: { webhooks: [] } } }), + ); +} + +test('API key list hides revoked records while keeping active keys after reload', async ({ + page, +}) => { + await installKeyMocks(page, [ + apiKey(1, 'Current integration', 'active'), + apiKey(2, 'Revoked integration', 'revoked'), + ]); + await page.goto('/home/bots?action=showApiIntegrationSettings'); + + await expect( + page.getByRole('cell', { name: 'Current integration' }), + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: 'Revoked integration' }), + ).toHaveCount(0); + await page.reload(); + await expect( + page.getByRole('cell', { name: 'Current integration' }), + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: 'Revoked integration' }), + ).toHaveCount(0); +}); + +test('deleting the last active API key shows the empty state after soft deletion and reload', async ({ + page, +}) => { + const keys = [apiKey(1, 'Disposable integration', 'active')]; + await installKeyMocks(page, keys); + await page.goto('/home/bots?action=showApiIntegrationSettings'); + + await page + .getByRole('row', { name: /Disposable integration/ }) + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + + await expect( + page.getByText('API key deleted successfully', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('No API keys configured', { exact: true }), + ).toBeVisible(); + expect(keys).toHaveLength(1); + expect(keys[0].status).toBe('revoked'); + + await page.reload(); + await expect( + page.getByText('No API keys configured', { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole('cell', { name: 'Disposable integration' }), + ).toHaveCount(0); +}); + +test('a failed revocation keeps the active API key visible', async ({ + page, +}) => { + const keys = [apiKey(1, 'Still active integration', 'active')]; + await installKeyMocks(page, keys, true); + await page.goto('/home/bots?action=showApiIntegrationSettings'); + + const row = page.getByRole('row', { name: /Still active integration/ }); + await row.getByRole('button', { name: 'Delete', exact: true }).click(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete', exact: true }) + .click(); + + await expect(page.getByText(/Failed to delete API key:/)).toBeVisible(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Cancel', exact: true }) + .click(); + await expect(row).toBeVisible(); + expect(keys[0].status).toBe('active'); +});