diff --git a/packages/@n8n/api-types/src/api-keys.ts b/packages/@n8n/api-types/src/api-keys.ts index aa9d47d512f..e3dd185f83f 100644 --- a/packages/@n8n/api-types/src/api-keys.ts +++ b/packages/@n8n/api-types/src/api-keys.ts @@ -18,4 +18,9 @@ export type ApiKey = { export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string }; +export type ApiKeyList = { + items: ApiKey[]; + count: number; +}; + export type ApiKeyAudience = 'public-api' | 'mcp-server-api'; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 0da34245b85..ec6ce862e81 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -140,7 +140,7 @@ export { export { TestDestinationQueryDto } from './log-streaming/test-destination-query.dto'; export { DeleteDestinationQueryDto } from './log-streaming/delete-destination-query.dto'; -export { PaginationDto } from './pagination/pagination.dto'; +export { PaginationDto, MAX_ITEMS_PER_PAGE } from './pagination/pagination.dto'; export { UsersListFilterDto, type UsersListSortOptions, diff --git a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts index ae1044ab3fd..81c278f98c4 100644 --- a/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/api-keys.controller.test.ts @@ -92,9 +92,7 @@ describe('ApiKeysController', () => { }); describe('getAPIKeys', () => { - it('should return the users api keys redacted', async () => { - // Arrange - + it('forwards pagination params to the service and returns its envelope', async () => { const apiKeyData = { id: '123', userId: '123', @@ -104,19 +102,17 @@ describe('ApiKeysController', () => { updatedAt: new Date(), } as ApiKey; - publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue([ - { ...apiKeyData, expiresAt: null }, - ]); + publicApiKeyService.getRedactedApiKeysForUser.mockResolvedValue({ + items: [{ ...apiKeyData, expiresAt: null }], + count: 1, + }); - // Act + const result = await controller.getApiKeys(req, mock(), { take: 10, skip: 5 } as never); - const apiKeys = await controller.getApiKeys(req); - - // Assert - - expect(apiKeys).toEqual([{ ...apiKeyData, expiresAt: null }]); + expect(result).toEqual({ items: [{ ...apiKeyData, expiresAt: null }], count: 1 }); expect(publicApiKeyService.getRedactedApiKeysForUser).toHaveBeenCalledWith( expect.objectContaining({ id: req.user.id }), + { take: 10, skip: 5 }, ); }); }); diff --git a/packages/cli/src/controllers/api-keys.controller.ts b/packages/cli/src/controllers/api-keys.controller.ts index 1242eb98d1e..f1a93716802 100644 --- a/packages/cli/src/controllers/api-keys.controller.ts +++ b/packages/cli/src/controllers/api-keys.controller.ts @@ -1,4 +1,4 @@ -import { CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; +import { CreateApiKeyRequestDto, PaginationDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; import { AuthenticatedRequest } from '@n8n/db'; import { Body, @@ -8,6 +8,7 @@ import { Param, Patch, Post, + Query, RestController, } from '@n8n/decorators'; import { getApiKeyScopesForRole } from '@n8n/permissions'; @@ -64,9 +65,11 @@ export class ApiKeysController { */ @GlobalScope('apiKey:manage') @Get('/', { middlewares: [isApiEnabledMiddleware] }) - async getApiKeys(req: AuthenticatedRequest) { - const apiKeys = await this.publicApiKeyService.getRedactedApiKeysForUser(req.user); - return apiKeys; + async getApiKeys(req: AuthenticatedRequest, _res: Response, @Query query: PaginationDto) { + return await this.publicApiKeyService.getRedactedApiKeysForUser(req.user, { + take: query.take, + skip: query.skip, + }); } /** diff --git a/packages/cli/src/services/public-api-key.service.ts b/packages/cli/src/services/public-api-key.service.ts index d69f887b84c..be04d44b979 100644 --- a/packages/cli/src/services/public-api-key.service.ts +++ b/packages/cli/src/services/public-api-key.service.ts @@ -45,19 +45,24 @@ export class PublicApiKeyService { } /** - * Retrieves and redacts API keys for a given user. - * @param user - The user for whom to retrieve and redact API keys. + * Retrieves a page of redacted API keys for a given user, ordered by + * `createdAt` descending. `count` is the total across all pages. */ - async getRedactedApiKeysForUser(user: User) { - const apiKeys = await this.apiKeyRepository.findBy({ - userId: user.id, - audience: API_KEY_AUDIENCE, + async getRedactedApiKeysForUser(user: User, options: { take?: number; skip?: number } = {}) { + const [apiKeys, count] = await this.apiKeyRepository.findAndCount({ + where: { userId: user.id, audience: API_KEY_AUDIENCE }, + order: { createdAt: 'DESC' }, + take: options.take, + skip: options.skip, }); - return apiKeys.map((apiKeyRecord) => ({ - ...apiKeyRecord, - apiKey: this.redactApiKey(apiKeyRecord.apiKey), - expiresAt: this.getApiKeyExpiration(apiKeyRecord.apiKey), - })); + return { + items: apiKeys.map((apiKeyRecord) => ({ + ...apiKeyRecord, + apiKey: this.redactApiKey(apiKeyRecord.apiKey), + expiresAt: this.getApiKeyExpiration(apiKeyRecord.apiKey), + })), + count, + }; } async deleteApiKeyForUser(user: User, apiKeyId: string) { diff --git a/packages/cli/test/integration/api-keys.api.test.ts b/packages/cli/test/integration/api-keys.api.test.ts index c283b372542..37309d8aa2f 100644 --- a/packages/cli/test/integration/api-keys.api.test.ts +++ b/packages/cli/test/integration/api-keys.api.test.ts @@ -238,7 +238,7 @@ describe('Owner shell', () => { const getApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); - const allApiKeys = getApiKeysResponse.body.data as ApiKeyWithRawValue[]; + const allApiKeys = getApiKeysResponse.body.data.items as ApiKeyWithRawValue[]; const updatedApiKey = allApiKeys.find((apiKey) => apiKey.id === newApiKey.id); @@ -266,7 +266,8 @@ describe('Owner shell', () => { expect(retrieveAllApiKeysResponse.statusCode).toBe(200); - expect(retrieveAllApiKeysResponse.body.data[1]).toEqual({ + expect(retrieveAllApiKeysResponse.body.data.count).toBe(2); + expect(retrieveAllApiKeysResponse.body.data.items[0]).toEqual({ id: apiKeyWithExpiration.body.data.id, label: 'My API Key 2', userId: ownerShell.id, @@ -279,7 +280,7 @@ describe('Owner shell', () => { lastUsedAt: null, }); - expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + expect(retrieveAllApiKeysResponse.body.data.items[1]).toEqual({ id: apiKeyWithNoExpiration.body.data.id, label: 'My API Key', userId: ownerShell.id, @@ -306,7 +307,8 @@ describe('Owner shell', () => { const retrieveAllApiKeysResponse = await testServer.authAgentFor(ownerShell).get('/api-keys'); expect(deleteApiKeyResponse.body.data.success).toBe(true); - expect(retrieveAllApiKeysResponse.body.data.length).toBe(0); + expect(retrieveAllApiKeysResponse.body.data.count).toBe(0); + expect(retrieveAllApiKeysResponse.body.data.items).toHaveLength(0); }); test('GET /api-keys/scopes should return scopes for the role', async () => { @@ -461,7 +463,8 @@ describe('Member', () => { expect(retrieveAllApiKeysResponse.statusCode).toBe(200); - expect(retrieveAllApiKeysResponse.body.data[1]).toEqual({ + expect(retrieveAllApiKeysResponse.body.data.count).toBe(2); + expect(retrieveAllApiKeysResponse.body.data.items[0]).toEqual({ id: apiKeyWithExpiration.body.data.id, label: 'My API Key 2', userId: member.id, @@ -474,7 +477,7 @@ describe('Member', () => { lastUsedAt: null, }); - expect(retrieveAllApiKeysResponse.body.data[0]).toEqual({ + expect(retrieveAllApiKeysResponse.body.data.items[1]).toEqual({ id: apiKeyWithNoExpiration.body.data.id, label: 'My API Key', userId: member.id, @@ -501,7 +504,8 @@ describe('Member', () => { const retrieveAllApiKeysResponse = await testServer.authAgentFor(member).get('/api-keys'); expect(deleteApiKeyResponse.body.data.success).toBe(true); - expect(retrieveAllApiKeysResponse.body.data.length).toBe(0); + expect(retrieveAllApiKeysResponse.body.data.count).toBe(0); + expect(retrieveAllApiKeysResponse.body.data.items).toHaveLength(0); }); test('GET /api-keys/scopes should return scopes for the role', async () => { @@ -514,3 +518,44 @@ describe('Member', () => { expect(scopes.sort()).toEqual(scopesForRole.sort()); }); }); + +describe('Pagination', () => { + const seedKeys = async (user: User, count: number): Promise => { + const agent = testServer.authAgentFor(user); + const ids: string[] = []; + for (let i = 0; i < count; i++) { + const res = await agent + .post('/api-keys') + .send({ label: `Key ${i}`, expiresAt: null, scopes: ['workflow:create'] }); + ids.push(res.body.data.id); + } + return ids; + }; + + test('GET /api-keys honors `take` and returns total count', async () => { + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + await seedKeys(owner, 3); + + const response = await testServer.authAgentFor(owner).get('/api-keys?take=2').expect(200); + + expect(response.body.data.count).toBe(3); + expect(response.body.data.items).toHaveLength(2); + }); + + test('GET /api-keys honors `skip` to page through results', async () => { + const owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + const createdIds = await seedKeys(owner, 3); + + const agent = testServer.authAgentFor(owner); + const firstPage = await agent.get('/api-keys?take=2&skip=0').expect(200); + const secondPage = await agent.get('/api-keys?take=2&skip=2').expect(200); + + expect(firstPage.body.data.items).toHaveLength(2); + expect(secondPage.body.data.items).toHaveLength(1); + + const pagedIds = [...firstPage.body.data.items, ...secondPage.body.data.items].map( + (k: { id: string }) => k.id, + ); + expect(new Set(pagedIds)).toEqual(new Set(createdIds)); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index cf7aee30fdb..6ec781043f5 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -67,6 +67,7 @@ export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode'; export { default as N8nNodeIcon } from './N8nNodeIcon'; export { default as N8nNotice } from './N8nNotice'; export { default as N8nOption } from './N8nOption'; +export { default as N8nPagination } from './N8nPagination'; export { default as N8nSectionHeader } from './N8nSectionHeader'; export { default as N8nSelectableList } from './N8nSelectableList'; export { default as N8nPreviewTag } from './PreviewTag/PreviewTag.vue'; diff --git a/packages/frontend/@n8n/rest-api-client/src/api/api-keys.ts b/packages/frontend/@n8n/rest-api-client/src/api/api-keys.ts index 92759ffae01..3618488050f 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/api-keys.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/api-keys.ts @@ -1,7 +1,7 @@ import type { CreateApiKeyRequestDto, UpdateApiKeyRequestDto, - ApiKey, + ApiKeyList, ApiKeyWithRawValue, } from '@n8n/api-types'; import type { ApiKeyScope } from '@n8n/permissions'; @@ -9,8 +9,11 @@ import type { ApiKeyScope } from '@n8n/permissions'; import type { IRestApiContext } from '../types'; import { makeRestApiRequest } from '../utils'; -export async function getApiKeys(context: IRestApiContext): Promise { - return await makeRestApiRequest(context, 'GET', '/api-keys'); +export async function getApiKeys( + context: IRestApiContext, + options: { take?: number; skip?: number } = {}, +): Promise { + return await makeRestApiRequest(context, 'GET', '/api-keys', options); } export async function getApiKeyScopes(context: IRestApiContext): Promise { diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.store.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.store.ts index 8b5e2d69d0e..84851c02070 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.store.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/apiKeys.store.ts @@ -7,16 +7,18 @@ import { computed, ref } from 'vue'; import type { ApiKey, CreateApiKeyRequestDto, UpdateApiKeyRequestDto } from '@n8n/api-types'; import type { ApiKeyScope } from '@n8n/permissions'; +const DEFAULT_PAGE_SIZE = 10; + export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { const apiKeys = ref([]); + /** Total number of API keys on the server across every page, not the size of the current page. */ + const apiKeysCount = ref(0); + const page = ref(1); + const pageSize = ref(DEFAULT_PAGE_SIZE); const availableScopes = ref([]); const rootStore = useRootStore(); - const apiKeysSortByCreationDate = computed(() => - apiKeys.value.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), - ); - const apiKeysById = computed(() => { return apiKeys.value.reduce( (acc, apiKey) => { @@ -32,22 +34,43 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { return availableScopes.value; }; - const getAndCacheApiKeys = async () => { - if (apiKeys.value.length) return apiKeys.value; - apiKeys.value = await publicApiApi.getApiKeys(rootStore.restApiContext); - return apiKeys.value; + const fetchApiKeys = async () => { + const response = await publicApiApi.getApiKeys(rootStore.restApiContext, { + take: pageSize.value, + skip: (page.value - 1) * pageSize.value, + }); + apiKeys.value = response.items; + apiKeysCount.value = response.count; + return response; + }; + + const setPage = async (newPage: number) => { + page.value = newPage; + await fetchApiKeys(); + }; + + const setPageSize = async (newPageSize: number) => { + pageSize.value = newPageSize; + page.value = 1; + await fetchApiKeys(); }; const createApiKey = async (payload: CreateApiKeyRequestDto) => { const newApiKey = await publicApiApi.createApiKey(rootStore.restApiContext, payload); - const { rawApiKey, ...rest } = newApiKey; - apiKeys.value.push(rest); + // New key lands at the top (createdAt DESC) — return to page 1 and refetch so + // every consumer sees the same server state regardless of which page they were on. + page.value = 1; + await fetchApiKeys(); return newApiKey; }; const deleteApiKey = async (id: string) => { await publicApiApi.deleteApiKey(rootStore.restApiContext, id); - apiKeys.value = apiKeys.value.filter((apiKey) => apiKey.id !== id); + // Refetching keeps `apiKeysCount` honest and handles the page-becomes-empty edge case. + const remaining = apiKeysCount.value - 1; + const lastPage = Math.max(1, Math.ceil(remaining / pageSize.value)); + if (page.value > lastPage) page.value = lastPage; + await fetchApiKeys(); }; const updateApiKey = async (id: string, payload: UpdateApiKeyRequestDto) => { @@ -57,14 +80,18 @@ export const useApiKeysStore = defineStore(STORES.API_KEYS, () => { }; return { - getAndCacheApiKeys, + fetchApiKeys, + setPage, + setPageSize, createApiKey, deleteApiKey, updateApiKey, getApiKeyAvailableScopes, - apiKeysSortByCreationDate, apiKeysById, apiKeys, + apiKeysCount, + page, + pageSize, availableScopes, }; }); diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts index 280d06498c1..d306f0da560 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.test.ts @@ -213,6 +213,58 @@ describe('SettingsApiView', () => { assertHintsAreShown({ isSwaggerUIEnabled: false }); }); + it('should hide the pagination when there is one page or fewer of keys', () => { + settingsStore.isPublicApiEnabled = true; + cloudStore.userIsTrialing = false; + apiKeysStore.apiKeys = [ + { + id: '1', + label: 'test-key-1', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + apiKey: '****Atcr', + expiresAt: null, + scopes: ['user:create'], + lastUsedAt: null, + }, + ]; + apiKeysStore.apiKeysCount = 1; + apiKeysStore.pageSize = 10; + + renderComponent(SettingsApiView); + + expect(screen.queryByTestId('api-keys-pagination')).not.toBeInTheDocument(); + }); + + it('should show the pagination and switch pages when there are more keys than fit on one page', async () => { + settingsStore.isPublicApiEnabled = true; + cloudStore.userIsTrialing = false; + apiKeysStore.apiKeys = [ + { + id: '1', + label: 'test-key-1', + createdAt: new Date().toString(), + updatedAt: new Date().toString(), + apiKey: '****Atcr', + expiresAt: null, + scopes: ['user:create'], + lastUsedAt: null, + }, + ]; + apiKeysStore.apiKeysCount = 25; + apiKeysStore.pageSize = 10; + apiKeysStore.page = 1; + + renderComponent(SettingsApiView); + + const pagination = screen.getByTestId('api-keys-pagination'); + expect(pagination).toBeInTheDocument(); + + await fireEvent.click(within(pagination).getByText('2')); + + expect(apiKeysStore.setPage).toHaveBeenCalledWith(2); + }); + it('should show delete warning when trying to delete an API key', async () => { settingsStore.isPublicApiEnabled = true; cloudStore.userIsTrialing = false; diff --git a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue index 0fe4ac2fdf1..6d1e92bd806 100644 --- a/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue +++ b/packages/frontend/editor-ui/src/features/settings/apiKeys/views/SettingsApiView.vue @@ -17,7 +17,14 @@ import { storeToRefs } from 'pinia'; import { useRootStore } from '@n8n/stores/useRootStore'; import { ElCol, ElRow } from 'element-plus'; -import { N8nActionBox, N8nButton, N8nHeading, N8nLink, N8nText } from '@n8n/design-system'; +import { + N8nActionBox, + N8nButton, + N8nHeading, + N8nLink, + N8nPagination, + N8nText, +} from '@n8n/design-system'; import { I18nT } from 'vue-i18n'; import ApiKeyCard from '../components/ApiKeyCard.vue'; @@ -34,8 +41,8 @@ const telemetry = useTelemetry(); const loading = ref(false); const apiKeysStore = useApiKeysStore(); -const { getAndCacheApiKeys, deleteApiKey, getApiKeyAvailableScopes } = apiKeysStore; -const { apiKeysSortByCreationDate } = storeToRefs(apiKeysStore); +const { fetchApiKeys, setPage, deleteApiKey, getApiKeyAvailableScopes } = apiKeysStore; +const { apiKeys, apiKeysCount, page, pageSize } = storeToRefs(apiKeysStore); const { isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } = settingsStore; const { baseUrl } = useRootStore(); @@ -71,7 +78,18 @@ function onUpgrade() { async function getApiKeysAndScopes() { try { loading.value = true; - await Promise.all([getAndCacheApiKeys(), getApiKeyAvailableScopes()]); + await Promise.all([fetchApiKeys(), getApiKeyAvailableScopes()]); + } catch (error) { + showError(error, i18n.baseText('settings.api.view.error')); + } finally { + loading.value = false; + } +} + +async function onPageChange(newPage: number) { + try { + loading.value = true; + await setPage(newPage); } catch (error) { showError(error, i18n.baseText('settings.api.view.error')); } finally { @@ -119,7 +137,7 @@ function onEdit(id: string) { {{ i18n.baseText('settings.api') }} -

+