mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 18:49:20 +02:00
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:
parent
3c61c305e0
commit
d327be0756
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user