Skip to content

Commit cc11472

Browse files
feat(clerk-js): Implement server-side pagination and filtering for API keys (#6453)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 88e3b64 commit cc11472

File tree

21 files changed

+453
-180
lines changed

21 files changed

+453
-180
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
"@clerk/shared": minor
4+
---
5+
6+
Implemented server-side pagination and filtering for API keys

integration/testUtils/usersService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ export const createUserService = (clerkClient: ClerkClient) => {
200200

201201
const apiKey = await clerkClient.apiKeys.create({
202202
subject: userId,
203-
name: `Integration Test - ${userId}`,
203+
name: `Integration Test - ${faker.string.uuid()}`,
204204
secondsUntilExpiration: TWENTY_MINUTES,
205205
});
206206

integration/tests/machine-auth/component.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,70 @@ testAgainstRunningApps({
6666
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(2);
6767
});
6868

69+
test('pagination works correctly with multiple pages', async ({ page, context }) => {
70+
const u = createTestUtils({ app, page, context });
71+
72+
// Create user and 11 API keys to trigger pagination (default perPage is 10)
73+
const fakeUser = u.services.users.createFakeUser();
74+
const bapiUser = await u.services.users.createBapiUser(fakeUser);
75+
const fakeAPIKeys = await Promise.all(
76+
Array.from({ length: 11 }, () => u.services.users.createFakeAPIKey(bapiUser.id)),
77+
);
78+
79+
await u.po.signIn.goTo();
80+
await u.po.signIn.waitForMounted();
81+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
82+
await u.po.expect.toBeSignedIn();
83+
84+
await u.po.page.goToRelative('/api-keys');
85+
await u.po.apiKeys.waitForMounted();
86+
87+
// Verify first page
88+
await expect(u.page.getByText(/Displaying 1 10 of 11/i)).toBeVisible();
89+
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(10);
90+
91+
// Navigate to second page
92+
const page2Button = u.page.locator('.cl-paginationButton').filter({ hasText: /^2$/ });
93+
await page2Button.click();
94+
await expect(u.page.getByText(/Displaying 11 11 of 11/i)).toBeVisible();
95+
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(1);
96+
97+
// Navigate back to first page
98+
const page1Button = u.page.locator('.cl-paginationButton').filter({ hasText: /^1$/ });
99+
await page1Button.click();
100+
await expect(u.page.getByText(/Displaying 1 10 of 11/i)).toBeVisible();
101+
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(10);
102+
103+
// Cleanup
104+
await Promise.all(fakeAPIKeys.map(key => key.revoke()));
105+
await fakeUser.deleteIfExists();
106+
});
107+
108+
test('pagination does not show when items fit in one page', async ({ page, context }) => {
109+
const u = createTestUtils({ app, page, context });
110+
await u.po.signIn.goTo();
111+
await u.po.signIn.waitForMounted();
112+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
113+
await u.po.expect.toBeSignedIn();
114+
115+
await u.po.page.goToRelative('/api-keys');
116+
await u.po.apiKeys.waitForMounted();
117+
118+
const apiKeyName = `${fakeAdmin.firstName}-single-page-${Date.now()}`;
119+
await u.po.apiKeys.clickAddButton();
120+
await u.po.apiKeys.waitForFormOpened();
121+
await u.po.apiKeys.typeName(apiKeyName);
122+
await u.po.apiKeys.selectExpiration('1d');
123+
await u.po.apiKeys.clickSaveButton();
124+
125+
await u.po.apiKeys.waitForCopyModalOpened();
126+
await u.po.apiKeys.clickCopyAndCloseButton();
127+
await u.po.apiKeys.waitForCopyModalClosed();
128+
await u.po.apiKeys.waitForFormClosed();
129+
130+
await expect(u.page.getByText(/Displaying.*of.*/i)).toBeHidden();
131+
});
132+
69133
test('can revoke api keys', async ({ page, context }) => {
70134
const u = createTestUtils({ app, page, context });
71135
await u.po.signIn.goTo();

packages/clerk-js/src/core/modules/apiKeys/index.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import type {
33
ApiKeyJSON,
44
APIKeyResource,
55
APIKeysNamespace,
6+
ClerkPaginatedResponse,
67
CreateAPIKeyParams,
78
GetAPIKeysParams,
89
RevokeAPIKeyParams,
910
} from '@clerk/shared/types';
1011

1112
import type { FapiRequestInit } from '@/core/fapiClient';
13+
import { convertPageToOffsetSearchParams } from '@/utils/convertPageToOffsetSearchParams';
1214

1315
import { APIKey, BaseResource } from '../../resources/internal';
1416

@@ -35,23 +37,24 @@ export class APIKeys implements APIKeysNamespace {
3537
};
3638
}
3739

38-
async getAll(params?: GetAPIKeysParams): Promise<APIKeyResource[]> {
39-
return BaseResource.clerk
40-
.getFapiClient()
41-
.request<{ api_keys: ApiKeyJSON[] }>({
42-
...(await this.getBaseFapiProxyOptions()),
43-
method: 'GET',
44-
path: '/api_keys',
45-
search: {
46-
subject: params?.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
47-
// TODO: (rob) Remove when server-side pagination is implemented.
48-
limit: '100',
49-
},
50-
})
51-
.then(res => {
52-
const apiKeysJSON = res.payload as unknown as { api_keys: ApiKeyJSON[] };
53-
return apiKeysJSON.api_keys.map(json => new APIKey(json));
54-
});
40+
async getAll(params?: GetAPIKeysParams): Promise<ClerkPaginatedResponse<APIKeyResource>> {
41+
return BaseResource._fetch({
42+
...(await this.getBaseFapiProxyOptions()),
43+
method: 'GET',
44+
path: '/api_keys',
45+
search: convertPageToOffsetSearchParams({
46+
...params,
47+
subject: params?.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
48+
query: params?.query ?? '',
49+
}),
50+
}).then(res => {
51+
const { data: apiKeys, total_count } = res as unknown as ClerkPaginatedResponse<ApiKeyJSON>;
52+
53+
return {
54+
total_count,
55+
data: apiKeys.map(apiKey => new APIKey(apiKey)),
56+
};
57+
});
5558
}
5659

5760
async create(params: CreateAPIKeyParams): Promise<APIKeyResource> {

packages/clerk-js/src/ui/components/ApiKeys/ApiKeyModal.tsx renamed to packages/clerk-js/src/ui/components/ApiKeys/APIKeyModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react';
33
import { Modal } from '@/ui/elements/Modal';
44
import type { ThemableCssProp } from '@/ui/styledSystem';
55

6-
type ApiKeyModalProps = React.ComponentProps<typeof Modal> & {
6+
type APIKeyModalProps = React.ComponentProps<typeof Modal> & {
77
modalRoot?: React.MutableRefObject<HTMLElement | null>;
88
};
99

@@ -33,7 +33,7 @@ const getScopedPortalContainerStyles = (modalRoot?: React.MutableRefObject<HTMLE
3333
];
3434
};
3535

36-
export const ApiKeyModal = ({ modalRoot, containerSx, ...modalProps }: ApiKeyModalProps) => {
36+
export const APIKeyModal = ({ modalRoot, containerSx, ...modalProps }: APIKeyModalProps) => {
3737
return (
3838
<Modal
3939
{...modalProps}

packages/clerk-js/src/ui/components/ApiKeys/ApiKeysTable.tsx renamed to packages/clerk-js/src/ui/components/ApiKeys/APIKeysTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu';
1919
import { mqu } from '@/ui/styledSystem';
2020
import { timeAgo } from '@/ui/utils/timeAgo';
2121

22-
export const ApiKeysTable = ({
22+
export const APIKeysTable = ({
2323
rows,
2424
isLoading,
2525
onRevoke,

0 commit comments

Comments
 (0)