Skip to content

feat(clerk-js): Server-side pagination of API keys component #6453

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 22 additions & 17 deletions packages/clerk-js/src/core/modules/apiKeys/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import type {
ApiKeyJSON,
APIKeyResource,
APIKeysNamespace,
ClerkPaginatedResponse,
CreateAPIKeyParams,
GetAPIKeysParams,
RevokeAPIKeyParams,
} from '@clerk/types';

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

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

Expand All @@ -34,23 +36,26 @@ 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 ?? '',
}),
}).then(res => {
const { api_keys: apiKeys, total_count } = res as unknown as {
api_keys: ApiKeyJSON[];
total_count: number;
};

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

async getSecret(id: string): Promise<string> {
Expand Down
42 changes: 42 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/APIKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { APIKey } from '../internal';

describe('APIKey', () => {
it('has the same initial properties', () => {
const apiKey = new APIKey({
object: 'api_key',
id: 'ak_12345',
type: 'api_key',
name: 'Test API Key',
subject: 'user_123',
scopes: ['read', 'write'],
claims: { custom: 'claim' },
revoked: false,
revocation_reason: null,
expired: false,
expiration: null,
created_by: 'user_456',
description: 'Test API key for testing',
last_used_at: 1754006990779,
created_at: 1754006990779,
updated_at: 1754006990779,
});

expect(apiKey).toMatchObject({
id: 'ak_12345',
type: 'api_key',
name: 'Test API Key',
subject: 'user_123',
scopes: ['read', 'write'],
claims: { custom: 'claim' },
revoked: false,
revocationReason: null,
expired: false,
expiration: null,
createdBy: 'user_456',
description: 'Test API key for testing',
lastUsedAt: new Date(1754006990779),
createdAt: new Date(1754006990779),
updatedAt: new Date(1754006990779),
});
});
});
3 changes: 2 additions & 1 deletion packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
value={search}
onChange={e => {
setSearch(e.target.value);
setPage(1);
// Don't reset page for client-side filtering
// setPage(1);
}}
elementDescriptor={descriptors.apiKeysSearchInput}
/>
Expand Down
135 changes: 135 additions & 0 deletions packages/clerk-js/src/ui/components/ApiKeys/__tests__/ApiKeys.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from 'vitest';

import { render, waitFor } from '../../../../vitestUtils';
import { bindCreateFixtures } from '../../../utils/vitest/createFixtures';
import { APIKeys } from '../ApiKeys';

const { createFixtures } = bindCreateFixtures('APIKeys');

function createFakeAPIKey(params: { id: string; name: string; createdAt: Date }) {
return {
id: params.id,
type: 'api_key',
name: params.name,
subject: 'user_123',
createdAt: params.createdAt,
};
}

describe('APIKeys', () => {
it('displays spinner when loading', async () => {
const { wrapper } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

const { baseElement } = render(<APIKeys />, { wrapper });

await waitFor(() => {
const spinner = baseElement.querySelector('span[aria-live="polite"]');
expect(spinner).toBeVisible();
});
});

it('renders API keys when data is loaded', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

fixtures.clerk.apiKeys.getAll = vi.fn().mockResolvedValue({
data: [
createFakeAPIKey({
id: 'ak_123',
name: 'Foo API Key',
createdAt: new Date('2024-01-01'),
}),
createFakeAPIKey({
id: 'ak_456',
name: 'Bar API Key',
createdAt: new Date('2024-02-01'),
}),
],
total_count: 2,
});

const { getByText } = render(<APIKeys />, { wrapper });

await waitFor(() => {
expect(getByText('Foo API Key')).toBeVisible();
expect(getByText('Created Jan 1, 2024 • Never expires')).toBeVisible();

expect(getByText('Bar API Key')).toBeVisible();
expect(getByText('Created Feb 1, 2024 • Never expires')).toBeVisible();
});
});

it('displays pagination when there are more items than per page', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

fixtures.clerk.apiKeys.getAll = vi.fn().mockResolvedValue({
data: Array.from({ length: 10 }, (_, i) =>
createFakeAPIKey({
id: `ak_${i}`,
name: `API Key ${i}`,
createdAt: new Date('2024-01-01'),
}),
),
total_count: 10,
});

const { getByText } = render(<APIKeys />, { wrapper });

await waitFor(() => {
expect(
getByText((_, element) => {
return element?.textContent === 'Displaying 1 – 5 of 10';
}),
).toBeVisible();
});
});

it('does not display pagination when items fit in one page', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

fixtures.clerk.apiKeys.getAll = vi.fn().mockResolvedValue({
data: [
createFakeAPIKey({
id: 'ak_123',
name: 'Test API Key',
createdAt: new Date('2024-01-01'),
}),
],
total_count: 1,
});

const { queryByText } = render(<APIKeys />, { wrapper });

await waitFor(() => {
expect(
queryByText((_, element) => {
return element?.textContent === 'Displaying 1 – 1 of 1';
}),
).not.toBeInTheDocument();
});
});

it('handles empty API keys list', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withUser({ email_addresses: ['[email protected]'] });
});

fixtures.clerk.apiKeys.getAll = vi.fn().mockResolvedValue({
data: [],
total_count: 0,
});

const { getByText } = render(<APIKeys />, { wrapper });

await waitFor(() => {
expect(getByText(/No API keys found/i)).toBeVisible();
});
});
});
70 changes: 33 additions & 37 deletions packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useClerk } from '@clerk/shared/react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import useSWR from 'swr';

export const useApiKeys = ({
Expand All @@ -12,61 +12,57 @@ export const useApiKeys = ({
enabled: boolean;
}) => {
const clerk = useClerk();
const [currentPage, setCurrentPage] = useState(1);

const cacheKey = {
key: 'api-keys',
subject,
perPage,
initialPage: currentPage,
};

const {
data: apiKeys,
data: apiKeysResource,
isLoading,
mutate,
} = useSWR(enabled ? cacheKey : null, () => clerk.apiKeys.getAll({ subject }));
} = useSWR(enabled ? cacheKey : null, () =>
clerk.apiKeys.getAll({
subject,
pageSize: perPage,
initialPage: currentPage,
}),
);

const apiKeys = useMemo(() => apiKeysResource?.data ?? [], [apiKeysResource]);
const totalCount = useMemo(() => apiKeysResource?.total_count ?? 0, [apiKeysResource]);
const [search, setSearch] = useState('');
const filteredApiKeys = (apiKeys ?? []).filter(key => key.name.toLowerCase().includes(search.toLowerCase()));

const {
page,
setPage,
pageCount,
itemCount,
startingRow,
endingRow,
paginatedItems: paginatedApiKeys,
} = useClientSidePagination(filteredApiKeys, perPage);
const filteredApiKeys = apiKeys.filter(key => key.name.toLowerCase().includes(search.toLowerCase()));

// Calculate pagination values based on server response
const pageCount = Math.max(1, Math.ceil(totalCount / perPage));
const startingRow = totalCount > 0 ? (currentPage - 1) * perPage + 1 : 0;
const endingRow = Math.min(currentPage * perPage, totalCount);

const handlePageChange = (page: number) => {
setCurrentPage(page);
// Reset search when changing pages
// TODO(rob): Server-side search is not implemented
setSearch('');
};

return {
apiKeys: paginatedApiKeys,
apiKeys: filteredApiKeys,
cacheKey,
mutate,
isLoading,
search,
setSearch,
page,
setPage,
pageCount,
itemCount,
startingRow,
endingRow,
};
};

const useClientSidePagination = <T>(items: T[], itemsPerPage: number) => {
const [page, setPage] = useState(1);

const itemCount = items.length;
const pageCount = Math.max(1, Math.ceil(itemCount / itemsPerPage));
const startingRow = itemCount > 0 ? (page - 1) * itemsPerPage + 1 : 0;
const endingRow = Math.min(page * itemsPerPage, itemCount);
const paginatedItems = items.slice(startingRow - 1, endingRow);

return {
page,
setPage,
page: currentPage,
setPage: handlePageChange,
pageCount,
itemCount,
itemCount: totalCount,
startingRow,
endingRow,
paginatedItems,
};
};
5 changes: 3 additions & 2 deletions packages/types/src/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CreateAPIKeyParams, GetAPIKeysParams, RevokeAPIKeyParams } from './clerk';
import type { ClerkPaginatedResponse } from './pagination';
import type { ClerkResource } from './resource';

export interface APIKeyResource extends ClerkResource {
Expand All @@ -24,9 +25,9 @@ export interface APIKeysNamespace {
* @experimental
* This API is in early access and may change in future releases.
*
* Retrieves all API keys for the current user or organization.
* Retrieves a paginated list of API keys for the current user or organization.
*/
getAll(params?: GetAPIKeysParams): Promise<APIKeyResource[]>;
getAll(params?: GetAPIKeysParams): Promise<ClerkPaginatedResponse<APIKeyResource>>;
/**
* @experimental
* This API is in early access and may change in future releases.
Expand Down
6 changes: 4 additions & 2 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ClerkPaginationParams } from 'pagination';

import type { ClerkAPIResponseError } from './api';
import type { APIKeysNamespace } from './apiKeys';
import type {
Expand Down Expand Up @@ -1813,9 +1815,9 @@ export type APIKeysProps = {
showDescription?: boolean;
};

export type GetAPIKeysParams = {
export type GetAPIKeysParams = ClerkPaginationParams<{
subject?: string;
};
}>;

export type CreateAPIKeyParams = {
type?: 'api_key';
Expand Down