Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
16d955a
chore: initial per page implementation
wobsoriano Jul 31, 2025
7ea24c2
chore: adjust prop names
wobsoriano Aug 1, 2025
aaccd90
chore: make server side pagination work
wobsoriano Aug 1, 2025
1ca2208
chore: update comments
wobsoriano Aug 1, 2025
e186744
basic resource test
wobsoriano Aug 1, 2025
d605cdd
chore: add pagination test via unit tests
wobsoriano Aug 4, 2025
e7046eb
chore: Allow server-side filtering
wobsoriano Nov 6, 2025
f780665
chore: add tests
wobsoriano Nov 6, 2025
72c3292
chore: add tests
wobsoriano Nov 6, 2025
cde9dab
Merge branch 'main' into rob/user-2501-api-keys-server-side
wobsoriano Nov 6, 2025
b6b63aa
chore: add changeset
wobsoriano Nov 6, 2025
828e0ef
chore: add timestamp to api key name
wobsoriano Nov 6, 2025
5f4aaef
chore: replace generated api key with uuid
wobsoriano Nov 6, 2025
df44842
chore: fix missing test imports
wobsoriano Nov 6, 2025
11de7fb
fix tests
wobsoriano Nov 6, 2025
36e6ffe
fix tests
wobsoriano Nov 6, 2025
c86d05f
Merge branch 'main' into rob/user-2501-api-keys-server-side
wobsoriano Nov 6, 2025
05d2b3d
Apply suggestions from code review
wobsoriano Nov 6, 2025
e350a72
chore: remove unit test
wobsoriano Nov 6, 2025
bcb2f34
chore: use existing pagination hook
wobsoriano Nov 7, 2025
7de59be
Merge branch 'main' into rob/user-2501-api-keys-server-side
wobsoriano Nov 7, 2025
be138e9
chore: clean up
wobsoriano Nov 7, 2025
892d7e0
chore: remove useless comment
wobsoriano Nov 7, 2025
16e18e5
chore: use actual page size in hook
wobsoriano Nov 7, 2025
00aa981
chore: consistent api key naming
wobsoriano Nov 7, 2025
4e372a1
chore: consistent api key naming
wobsoriano Nov 7, 2025
9ed2700
Merge branch 'main' into rob/user-2501-api-keys-server-side
wobsoriano Nov 7, 2025
6fa1faf
fix e2e
wobsoriano Nov 7, 2025
bba3547
chore: use API_KEYS_PAGE_SIZE const as default perPage
wobsoriano Nov 7, 2025
cb762d4
fix turbo cache
wobsoriano Nov 7, 2025
86e6a07
Update packages/shared/src/react/hooks/useAPIKeys.ts
wobsoriano Nov 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-papayas-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/clerk-js": minor
"@clerk/shared": minor
---

Implemented server-side pagination and filtering for API keys
2 changes: 1 addition & 1 deletion integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export const createUserService = (clerkClient: ClerkClient) => {

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

Expand Down
64 changes: 64 additions & 0 deletions integration/tests/machine-auth/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,70 @@ testAgainstRunningApps({
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(2);
});

test('pagination works correctly with multiple pages', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

// Create user and 11 API keys to trigger pagination (default perPage is 10)
const fakeUser = u.services.users.createFakeUser();
const bapiUser = await u.services.users.createBapiUser(fakeUser);
const fakeAPIKeys = await Promise.all(
Array.from({ length: 11 }, () => u.services.users.createFakeAPIKey(bapiUser.id)),
);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();

await u.po.page.goToRelative('/api-keys');
await u.po.apiKeys.waitForMounted();

// Verify first page
await expect(u.page.getByText(/Displaying 1 – 10 of 11/i)).toBeVisible();
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(10);

// Navigate to second page
const page2Button = u.page.locator('.cl-paginationButton').filter({ hasText: /^2$/ });
await page2Button.click();
await expect(u.page.getByText(/Displaying 11 – 11 of 11/i)).toBeVisible();
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(1);

// Navigate back to first page
const page1Button = u.page.locator('.cl-paginationButton').filter({ hasText: /^1$/ });
await page1Button.click();
await expect(u.page.getByText(/Displaying 1 – 10 of 11/i)).toBeVisible();
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(10);

// Cleanup
await Promise.all(fakeAPIKeys.map(key => key.revoke()));
await fakeUser.deleteIfExists();
});

test('pagination does not show when items fit in one page', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
await u.po.expect.toBeSignedIn();

await u.po.page.goToRelative('/api-keys');
await u.po.apiKeys.waitForMounted();

const apiKeyName = `${fakeAdmin.firstName}-single-page-${Date.now()}`;
await u.po.apiKeys.clickAddButton();
await u.po.apiKeys.waitForFormOpened();
await u.po.apiKeys.typeName(apiKeyName);
await u.po.apiKeys.selectExpiration('1d');
await u.po.apiKeys.clickSaveButton();

await u.po.apiKeys.waitForCopyModalOpened();
await u.po.apiKeys.clickCopyAndCloseButton();
await u.po.apiKeys.waitForCopyModalClosed();
await u.po.apiKeys.waitForFormClosed();

await expect(u.page.getByText(/Displaying.*of.*/i)).toBeHidden();
});

test('can revoke api keys', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
Expand Down
37 changes: 20 additions & 17 deletions packages/clerk-js/src/core/modules/apiKeys/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import type {
ApiKeyJSON,
APIKeyResource,
APIKeysNamespace,
ClerkPaginatedResponse,
CreateAPIKeyParams,
GetAPIKeysParams,
RevokeAPIKeyParams,
} from '@clerk/shared/types';

import type { FapiRequestInit } from '@/core/fapiClient';
import { convertPageToOffsetSearchParams } from '@/utils/convertPageToOffsetSearchParams';

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

Expand All @@ -35,23 +37,24 @@ export class APIKeys implements APIKeysNamespace {
};
}

async getAll(params?: GetAPIKeysParams): Promise<APIKeyResource[]> {
return BaseResource.clerk
.getFapiClient()
.request<{ api_keys: ApiKeyJSON[] }>({
...(await this.getBaseFapiProxyOptions()),
method: 'GET',
path: '/api_keys',
search: {
subject: params?.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
// TODO: (rob) Remove when server-side pagination is implemented.
limit: '100',
},
})
.then(res => {
const apiKeysJSON = res.payload as unknown as { api_keys: ApiKeyJSON[] };
return apiKeysJSON.api_keys.map(json => new APIKey(json));
});
async getAll(params?: GetAPIKeysParams): Promise<ClerkPaginatedResponse<APIKeyResource>> {
return BaseResource._fetch({
...(await this.getBaseFapiProxyOptions()),
method: 'GET',
path: '/api_keys',
search: convertPageToOffsetSearchParams({
...params,
subject: params?.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
query: params?.query ?? '',
}),
}).then(res => {
const { data: apiKeys, total_count } = res as unknown as ClerkPaginatedResponse<ApiKeyJSON>;

return {
total_count,
data: apiKeys.map(apiKey => new APIKey(apiKey)),
};
});
}

async create(params: CreateAPIKeyParams): Promise<APIKeyResource> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react';
import { Modal } from '@/ui/elements/Modal';
import type { ThemableCssProp } from '@/ui/styledSystem';

type ApiKeyModalProps = React.ComponentProps<typeof Modal> & {
type APIKeyModalProps = React.ComponentProps<typeof Modal> & {
modalRoot?: React.MutableRefObject<HTMLElement | null>;
};

Expand Down Expand Up @@ -33,7 +33,7 @@ const getScopedPortalContainerStyles = (modalRoot?: React.MutableRefObject<HTMLE
];
};

export const ApiKeyModal = ({ modalRoot, containerSx, ...modalProps }: ApiKeyModalProps) => {
export const APIKeyModal = ({ modalRoot, containerSx, ...modalProps }: APIKeyModalProps) => {
return (
<Modal
{...modalProps}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu';
import { mqu } from '@/ui/styledSystem';
import { timeAgo } from '@/ui/utils/timeAgo';

export const ApiKeysTable = ({
export const APIKeysTable = ({
rows,
isLoading,
onRevoke,
Expand Down
Loading
Loading