feat(core): Paginate the API keys list endpoint (#31500)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ricardo Espinoza 2026-06-01 15:28:13 -04:00 committed by GitHub
parent 3c61c305e0
commit d327be0756
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 234 additions and 66 deletions

View File

@ -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';

View File

@ -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,

View File

@ -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 },
);
});
});

View File

@ -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,
});
}
/**

View File

@ -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) {

View File

@ -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<string[]> => {
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));
});
});

View File

@ -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';

View File

@ -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<ApiKey[]> {
return await makeRestApiRequest(context, 'GET', '/api-keys');
export async function getApiKeys(
context: IRestApiContext,
options: { take?: number; skip?: number } = {},
): Promise<ApiKeyList> {
return await makeRestApiRequest(context, 'GET', '/api-keys', options);
}
export async function getApiKeyScopes(context: IRestApiContext): Promise<ApiKeyScope[]> {

View File

@ -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<ApiKey[]>([]);
/** 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<ApiKeyScope[]>([]);
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,
};
});

View File

@ -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;

View File

@ -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') }}
</N8nHeading>
</div>
<p v-if="isPublicApiEnabled && apiKeysSortByCreationDate.length" :class="$style.topHint">
<p v-if="isPublicApiEnabled && apiKeys.length" :class="$style.topHint">
<N8nText>
<I18nT keypath="settings.api.view.info" tag="span" scope="global">
<template #apiAction>
@ -143,12 +161,12 @@ function onEdit(id: string) {
</p>
<div :class="$style.apiKeysContainer">
<template v-if="apiKeysSortByCreationDate.length">
<template v-if="apiKeys.length">
<ElRow
v-for="(apiKey, index) in apiKeysSortByCreationDate"
v-for="(apiKey, index) in apiKeys"
:key="apiKey.id"
:gutter="10"
:class="[{ [$style.destinationItem]: index !== apiKeysSortByCreationDate.length - 1 }]"
:class="[{ [$style.destinationItem]: index !== apiKeys.length - 1 }]"
>
<ElCol>
<ApiKeyCard :api-key="apiKey" @delete="onDelete" @edit="onEdit" />
@ -157,7 +175,18 @@ function onEdit(id: string) {
</template>
</div>
<div v-if="isPublicApiEnabled && apiKeysSortByCreationDate.length" :class="$style.BottomHint">
<div v-if="apiKeysCount > pageSize" :class="$style.pagination">
<N8nPagination
:current-page="page"
:page-size="pageSize"
:total="apiKeysCount"
layout="total, prev, pager, next"
data-test-id="api-keys-pagination"
@current-change="onPageChange"
/>
</div>
<div v-if="isPublicApiEnabled && apiKeys.length" :class="$style.BottomHint">
<N8nText size="small" color="text-light">
{{
i18n.baseText(
@ -186,11 +215,7 @@ function onEdit(id: string) {
</N8nLink>
</div>
<div class="mt-m text-right">
<N8nButton
v-if="isPublicApiEnabled && apiKeysSortByCreationDate.length"
size="large"
@click="onCreateApiKey"
>
<N8nButton v-if="isPublicApiEnabled && apiKeys.length" size="large" @click="onCreateApiKey">
{{ i18n.baseText('settings.api.create.button') }}
</N8nButton>
</div>
@ -205,7 +230,7 @@ function onEdit(id: string) {
/>
<N8nActionBox
v-if="isPublicApiEnabled && !apiKeysSortByCreationDate.length"
v-if="isPublicApiEnabled && !apiKeys.length"
:button-text="
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
"
@ -265,4 +290,10 @@ function onEdit(id: string) {
overflow-x: hidden;
scrollbar-width: none;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing--sm);
}
</style>