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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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