diff --git a/packages/clerk-js/src/core/modules/apiKeys/index.ts b/packages/clerk-js/src/core/modules/apiKeys/index.ts index f4b538ce893..9840b146ca9 100644 --- a/packages/clerk-js/src/core/modules/apiKeys/index.ts +++ b/packages/clerk-js/src/core/modules/apiKeys/index.ts @@ -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'; @@ -34,23 +36,26 @@ export class APIKeys implements APIKeysNamespace { }; } - async getAll(params?: GetAPIKeysParams): Promise { - 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> { + 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 { diff --git a/packages/clerk-js/src/core/resources/__tests__/APIKey.test.ts b/packages/clerk-js/src/core/resources/__tests__/APIKey.test.ts new file mode 100644 index 00000000000..303968df6d1 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/APIKey.test.ts @@ -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), + }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx index 37b25301659..fead68a736e 100644 --- a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx +++ b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx @@ -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} /> diff --git a/packages/clerk-js/src/ui/components/ApiKeys/__tests__/ApiKeys.spec.tsx b/packages/clerk-js/src/ui/components/ApiKeys/__tests__/ApiKeys.spec.tsx new file mode 100644 index 00000000000..e51f46a1b3e --- /dev/null +++ b/packages/clerk-js/src/ui/components/ApiKeys/__tests__/ApiKeys.spec.tsx @@ -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: ['test@clerk.com'] }); + }); + + const { baseElement } = render(, { 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: ['test@clerk.com'] }); + }); + + 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(, { 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: ['test@clerk.com'] }); + }); + + 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(, { 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: ['test@clerk.com'] }); + }); + + 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(, { 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: ['test@clerk.com'] }); + }); + + fixtures.clerk.apiKeys.getAll = vi.fn().mockResolvedValue({ + data: [], + total_count: 0, + }); + + const { getByText } = render(, { wrapper }); + + await waitFor(() => { + expect(getByText(/No API keys found/i)).toBeVisible(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts b/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts index 4441750eb65..02341771334 100644 --- a/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts +++ b/packages/clerk-js/src/ui/components/ApiKeys/useApiKeys.ts @@ -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 = ({ @@ -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 = (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, }; }; diff --git a/packages/types/src/apiKeys.ts b/packages/types/src/apiKeys.ts index 64f74587b18..be256a6ab80 100644 --- a/packages/types/src/apiKeys.ts +++ b/packages/types/src/apiKeys.ts @@ -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 { @@ -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; + getAll(params?: GetAPIKeysParams): Promise>; /** * @experimental * This API is in early access and may change in future releases. diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bb5ab633869..48ba37a1197 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1,3 +1,5 @@ +import type { ClerkPaginationParams } from 'pagination'; + import type { ClerkAPIResponseError } from './api'; import type { APIKeysNamespace } from './apiKeys'; import type { @@ -1813,9 +1815,9 @@ export type APIKeysProps = { showDescription?: boolean; }; -export type GetAPIKeysParams = { +export type GetAPIKeysParams = ClerkPaginationParams<{ subject?: string; -}; +}>; export type CreateAPIKeyParams = { type?: 'api_key';