Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5a9b906
chore(clerk-js): Remove key column in API keys component
wobsoriano Oct 31, 2025
19a8085
chore: update callout text
wobsoriano Oct 31, 2025
6587f25
Merge branch 'main' into rob/user-3698-remove-key-column
wobsoriano Oct 31, 2025
de27fdb
chore: Improve Alert component
wobsoriano Oct 31, 2025
48d7896
remove console log
wobsoriano Oct 31, 2025
817b8b5
chore: remove unused methods
wobsoriano Oct 31, 2025
35016e7
chore: add locales
wobsoriano Nov 1, 2025
b4a8f1c
chore: use existing created api key data for alert
wobsoriano Nov 1, 2025
0407380
test: remove obsolete tests and test alert with copy secret
wobsoriano Nov 2, 2025
c68a03b
chore: add changeset for clerk-js
wobsoriano Nov 2, 2025
996c579
chore: add changeset for localizations
wobsoriano Nov 2, 2025
51e5a68
chore: add changeset for backend
wobsoriano Nov 2, 2025
4fb8000
test: fix secret
wobsoriano Nov 2, 2025
def5944
Merge branch 'main' into rob/user-3698-remove-key-column
wobsoriano Nov 3, 2025
5ec60e9
chore: dedupe
wobsoriano Nov 3, 2025
03a3651
chore(clerk-js): Switch to modal approach in API keys copying (#7134)
wobsoriano Nov 3, 2025
17bbb98
chore: update changesets
wobsoriano Nov 3, 2025
d6fa9bc
chore: dedupe
wobsoriano Nov 3, 2025
ca40895
Merge branch 'main' into rob/user-3698-remove-key-column
wobsoriano Nov 3, 2025
9209f76
chore: create reusable modal
wobsoriano Nov 3, 2025
a0838b1
chore: add jsdoc to secret property
wobsoriano Nov 3, 2025
be632c4
Merge branch 'main' into rob/user-3698-remove-key-column
wobsoriano Nov 4, 2025
b5254e8
chore: hide create form after modal closes
wobsoriano Nov 4, 2025
4aeab7b
chore: add small delay before hiding create form
wobsoriano Nov 4, 2025
b0d3251
chore: update element IDs
wobsoriano Nov 4, 2025
702f5a9
Merge branch 'main' into rob/user-3698-remove-key-column
wobsoriano Nov 4, 2025
c43295b
chore: revert backend change
wobsoriano Nov 4, 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
5 changes: 5 additions & 0 deletions .changeset/chilly-boxes-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/localizations": minor
---

Added localization entry for the API key copy modal component.
5 changes: 5 additions & 0 deletions .changeset/heavy-books-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/clerk-js": minor
---

Replaced the persistent key column in the API keys table with a one-time modal that displays the secret immediately after creation.
4 changes: 1 addition & 3 deletions integration/testUtils/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,9 @@ export const createUserService = (clerkClient: ClerkClient) => {
secondsUntilExpiration: TWENTY_MINUTES,
});

const { secret } = await clerkClient.apiKeys.getSecret(apiKey.id);

return {
apiKey,
secret,
secret: apiKey.secret ?? '',
revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }),
} satisfies FakeAPIKey;
},
Expand Down
77 changes: 24 additions & 53 deletions integration/tests/machine-auth/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ testAgainstRunningApps({
await expect(table.locator('.cl-tableRow', { hasText: apiKeyName })).toHaveCount(0);
});

test('can copy api key secret', async ({ page, context }) => {
test('can copy api key secret after creation', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
Expand All @@ -109,71 +109,42 @@ testAgainstRunningApps({

const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`;

// Wait for create API response to get the secret
const createResponsePromise = page.waitForResponse(
response => response.url().includes('/api_keys') && response.request().method() === 'POST',
);

// Create API key
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.waitForFormClosed();

const responsePromise = page.waitForResponse(
response => response.url().includes('/secret') && response.request().method() === 'GET',
);
// Get the secret from the create response
const createResponse = await createResponsePromise;
const apiKeyData = await createResponse.json();
const secret = apiKeyData.secret;

// Copy API key
const table = u.page.locator('.cl-apiKeysTable');
const row = table.locator('.cl-tableRow', { hasText: apiKeyName });
await row.waitFor({ state: 'attached' });
await row.locator('.cl-apiKeysCopyButton').click();
// Wait for the copy modal to appear
const copyModal = page.locator('.cl-apiKeysCopyModal');
await copyModal.waitFor({ state: 'attached' });

// Read clipboard contents
const data = await (await responsePromise).json();
// Grant clipboard permissions before clicking the button
await context.grantPermissions(['clipboard-read']);
const clipboardText = await page.evaluate('navigator.clipboard.readText()');
await context.clearPermissions();
expect(clipboardText).toBe(data.secret);
});

test('can toggle api key secret visibility', 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}-${Date.now()}`;

// Create API key
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.waitForFormClosed();

const responsePromise = page.waitForResponse(
response => response.url().includes('/secret') && response.request().method() === 'GET',
);
// Click "Copy & Close" button which will copy the secret and close the modal
const copyAndCloseButton = copyModal.locator('.cl-apiKeysCopyModalSubmitButton');
await copyAndCloseButton.waitFor({ state: 'attached' });
await copyAndCloseButton.click();

// Toggle API key secret visibility
const table = u.page.locator('.cl-apiKeysTable');
const row = table.locator('.cl-tableRow', { hasText: apiKeyName });
await row.waitFor({ state: 'attached' });
await expect(row.locator('input')).toHaveAttribute('type', 'password');
await row.locator('.cl-apiKeysRevealButton').click();

// Verify if secret matches the input value
const data = await (await responsePromise).json();
await expect(row.locator('input')).toHaveAttribute('type', 'text');
await expect(row.locator('input')).toHaveValue(data.secret);
// Wait for modal to close
await copyModal.waitFor({ state: 'detached' });

// Toggle visibility off
await row.locator('.cl-apiKeysRevealButton').click();
await expect(row.locator('input')).toHaveAttribute('type', 'password');
// Read clipboard contents to verify the secret was copied
const clipboardText = await page.evaluate('navigator.clipboard.readText()');
await context.clearPermissions();
expect(clipboardText).toBe(secret);
});

test('component does not render for orgs when user does not have permissions', async ({ page, context }) => {
Expand Down
58 changes: 20 additions & 38 deletions packages/clerk-js/src/core/modules/apiKeys/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,50 +54,32 @@ export class APIKeys implements APIKeysNamespace {
});
}

async getSecret(id: string): Promise<string> {
return BaseResource.clerk
.getFapiClient()
.request<{ secret: string }>({
...(await this.getBaseFapiProxyOptions()),
method: 'GET',
path: `/api_keys/${id}/secret`,
})
.then(res => {
const { secret } = res.payload as unknown as { secret: string };
return secret;
});
}

async create(params: CreateAPIKeyParams): Promise<APIKeyResource> {
const json = (
await BaseResource._fetch<ApiKeyJSON>({
...(await this.getBaseFapiProxyOptions()),
path: '/api_keys',
method: 'POST',
body: JSON.stringify({
type: params.type ?? 'api_key',
name: params.name,
subject: params.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
description: params.description,
seconds_until_expiration: params.secondsUntilExpiration,
}),
})
)?.response as ApiKeyJSON;
const json = (await BaseResource._fetch<ApiKeyJSON>({
...(await this.getBaseFapiProxyOptions()),
path: '/api_keys',
method: 'POST',
body: JSON.stringify({
type: params.type ?? 'api_key',
name: params.name,
subject: params.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
description: params.description,
seconds_until_expiration: params.secondsUntilExpiration,
}),
})) as unknown as ApiKeyJSON;

return new APIKey(json);
}

async revoke(params: RevokeAPIKeyParams): Promise<APIKeyResource> {
const json = (
await BaseResource._fetch<ApiKeyJSON>({
...(await this.getBaseFapiProxyOptions()),
method: 'POST',
path: `/api_keys/${params.apiKeyID}/revoke`,
body: JSON.stringify({
revocation_reason: params.revocationReason,
}),
})
)?.response as ApiKeyJSON;
const json = (await BaseResource._fetch<ApiKeyJSON>({
...(await this.getBaseFapiProxyOptions()),
method: 'POST',
path: `/api_keys/${params.apiKeyID}/revoke`,
body: JSON.stringify({
revocation_reason: params.revocationReason,
}),
})) as unknown as ApiKeyJSON;

return new APIKey(json);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/APIKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class APIKey extends BaseResource implements APIKeyResource {
expiration!: Date | null;
createdBy!: string | null;
description!: string | null;
secret?: string;
lastUsedAt!: Date | null;
createdAt!: Date;
updatedAt!: Date;
Expand All @@ -44,6 +45,7 @@ export class APIKey extends BaseResource implements APIKeyResource {
this.expiration = data.expiration ? unixEpochToDate(data.expiration) : null;
this.createdBy = data.created_by;
this.description = data.description;
this.secret = data.secret;
this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null;
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);
Expand Down
44 changes: 44 additions & 0 deletions packages/clerk-js/src/ui/components/ApiKeys/ApiKeyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';

import { Modal } from '@/ui/elements/Modal';
import type { ThemableCssProp } from '@/ui/styledSystem';

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

/**
* Container styles for modals rendered within a custom portal root (e.g., UserProfile or OrganizationProfile).
* When a modalRoot is provided, the modal is scoped to that container rather than the document root,
* requiring different positioning (absolute instead of fixed) and backdrop styling.
*/
const getScopedPortalContainerStyles = (modalRoot?: React.MutableRefObject<HTMLElement | null>): ThemableCssProp => {
return [
{ alignItems: 'center' },
modalRoot
? t => ({
position: 'absolute',
right: 0,
bottom: 0,
backgroundColor: 'inherit',
backdropFilter: `blur(${t.sizes.$2})`,
display: 'flex',
justifyContent: 'center',
minHeight: '100%',
height: '100%',
width: '100%',
borderRadius: t.radii.$lg,
})
: {},
];
};

export const ApiKeyModal = ({ modalRoot, containerSx, ...modalProps }: ApiKeyModalProps) => {
return (
<Modal
{...modalProps}
portalRoot={modalRoot}
containerSx={[getScopedPortalContainerStyles(modalRoot), containerSx]}
/>
);
};
33 changes: 27 additions & 6 deletions packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ const RevokeAPIKeyConfirmationModal = lazy(() =>
})),
);

const CopyApiKeyModal = lazy(() =>
import(/* webpackChunkName: "copy-api-key-modal"*/ './CopyApiKeyModal').then(module => ({
default: module.CopyApiKeyModal,
})),
);

export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => {
const isOrg = isOrganizationId(subject);
const canReadAPIKeys = useProtect({ permission: 'org:sys_api_keys:read' });
Expand All @@ -61,23 +67,26 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
cacheKey,
} = useApiKeys({ subject, perPage, enabled: isOrg ? canReadAPIKeys : true });
const card = useCardState();
const { trigger: createApiKey, isMutating } = useSWRMutation(cacheKey, (_, { arg }: { arg: CreateAPIKeyParams }) =>
clerk.apiKeys.create(arg),
);
const { t } = useLocalizations();
const clerk = useClerk();
const {
data: createdApiKey,
trigger: createApiKey,
isMutating,
} = useSWRMutation(cacheKey, (_key, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg));
const { t } = useLocalizations();
Comment on lines +71 to +76
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Avoid reusing the list cache key for mutation; revalidate list after create

Using useSWRMutation(cacheKey, …) risks cache shape clashes and the list not refreshing. Use a distinct mutation key and revalidate the list upon success.

Apply:

+import { useSWRConfig } from 'swr';
@@
 export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => {
@@
-  const {
+  const {
     data: createdApiKey,
     trigger: createApiKey,
     isMutating,
-  } = useSWRMutation(cacheKey, (_key, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg));
+  } = useSWRMutation(
+    `${cacheKey}-create`,
+    (_key, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg),
+  );
+  const { mutate } = useSWRConfig();
@@
   const handleCreateApiKey = async (params: OnCreateParams, closeCardFn: () => void) => {
     try {
-      await createApiKey({
+      await createApiKey({
         ...params,
         subject,
       });
+      // refresh list
+      await mutate(cacheKey);
       closeCardFn();
       card.setError(undefined);
       setShowCopyAlert(true);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx around lines 66 to
71, the mutation currently reuses the list cacheKey which can cause cache shape
clashes and prevent the list from refreshing; change the mutation to use a
distinct key (e.g. `${cacheKey}/create` or a symbol) when calling
useSWRMutation, and after a successful createApiKey call trigger revalidate of
the list cache by calling the SWR mutate for the original cacheKey (for example
import { mutate } from 'swr' and call await mutate(cacheKey) or
useSWRConfig().mutate(cacheKey) so the list is refreshed).

const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
const [selectedApiKeyId, setSelectedApiKeyId] = useState('');
const [selectedApiKeyName, setSelectedApiKeyName] = useState('');
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);

const handleCreateApiKey = async (params: OnCreateParams, closeCardFn: () => void) => {
const handleCreateApiKey = async (params: OnCreateParams) => {
try {
await createApiKey({
...params,
subject,
});
closeCardFn();
card.setError(undefined);
setIsCopyModalOpen(true);
} catch (err: any) {
if (isClerkAPIResponseError(err)) {
if (err.status === 409) {
Expand Down Expand Up @@ -147,7 +156,17 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
</Action.Card>
</Flex>
</Action.Open>

<CopyApiKeyModal
isOpen={isCopyModalOpen}
onOpen={() => setIsCopyModalOpen(true)}
onClose={() => setIsCopyModalOpen(false)}
apiKeyName={createdApiKey?.name ?? ''}
apiKeySecret={createdApiKey?.secret ?? ''}
modalRoot={revokeModalRoot}
/>
</Action.Root>

<ApiKeysTable
rows={apiKeys}
isLoading={isLoading}
Expand All @@ -164,6 +183,8 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
rowInfo={{ allRowsCount: itemCount, startingRow, endingRow }}
/>
)}

{/* Modals */}
<RevokeAPIKeyConfirmationModal
subject={subject}
isOpen={isRevokeModalOpen}
Expand Down
Loading
Loading