Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
35 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
6fceef4
chore: fix tests
wobsoriano Nov 5, 2025
9b30479
chore(clerk-js): Add missing locales for copy secret modal (#7160)
wobsoriano Nov 5, 2025
f352921
Update packages/localizations/src/nl-NL.ts
wobsoriano Nov 5, 2025
f223828
chore: Add missing formFieldLabel__apiKey translations
wobsoriano Nov 5, 2025
f7f4549
chore: apply mutation suggestions
wobsoriano Nov 5, 2025
e8992ae
Apply suggestion from @coderabbitai[bot]
wobsoriano Nov 5, 2025
6537f6e
Apply suggestion from @coderabbitai[bot]
wobsoriano Nov 5, 2025
020398f
Apply suggestion from @coderabbitai[bot]
wobsoriano Nov 5, 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
88 changes: 31 additions & 57 deletions integration/tests/machine-auth/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ testAgainstRunningApps({
await u.po.apiKeys.selectExpiration('1d');
await u.po.apiKeys.clickSaveButton();

// Close copy modal
await u.po.apiKeys.waitForCopyModalOpened();
await u.po.apiKeys.clickCopyAndCloseButton();
await u.po.apiKeys.waitForCopyModalClosed();
await u.po.apiKeys.waitForFormClosed();

// Create API key 2
Expand All @@ -52,8 +56,14 @@ testAgainstRunningApps({
await u.po.apiKeys.selectExpiration('7d');
await u.po.apiKeys.clickSaveButton();

// Wait and close copy modal
await u.po.apiKeys.waitForCopyModalOpened();
await u.po.apiKeys.clickCopyAndCloseButton();
await u.po.apiKeys.waitForCopyModalClosed();
await u.po.apiKeys.waitForFormClosed();

// Check if both API keys are created
await expect(u.page.locator('.cl-apiKeysTable .cl-tableRow')).toHaveCount(2);
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(2);
});

test('can revoke api keys', async ({ page, context }) => {
Expand All @@ -74,6 +84,11 @@ testAgainstRunningApps({
await u.po.apiKeys.typeName(apiKeyName);
await u.po.apiKeys.selectExpiration('1d');
await u.po.apiKeys.clickSaveButton();

// Wait and close copy modal
await u.po.apiKeys.waitForCopyModalOpened();
await u.po.apiKeys.clickCopyAndCloseButton();
await u.po.apiKeys.waitForCopyModalClosed();
await u.po.apiKeys.waitForFormClosed();

// Retrieve API key
Expand All @@ -97,7 +112,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 +124,30 @@ testAgainstRunningApps({

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

// Create API key
// Create API key and capture the secret from the response
const createResponsePromise = page.waitForResponse(
response => response.url().includes('/api_keys') && response.request().method() === 'POST',
);
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',
);

// 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();
const createResponse = await createResponsePromise;
const secret = (await createResponse.json()).secret;

// Read clipboard contents
const data = await (await responsePromise).json();
// Copy secret via modal and verify clipboard contents
// Wait and close copy modal
await u.po.apiKeys.waitForCopyModalOpened();
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.clickCopyAndCloseButton();
await u.po.apiKeys.waitForCopyModalClosed();
await u.po.apiKeys.waitForFormClosed();

const responsePromise = page.waitForResponse(
response => response.url().includes('/secret') && response.request().method() === 'GET',
);

// 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);

// Toggle visibility off
await row.locator('.cl-apiKeysRevealButton').click();
await expect(row.locator('input')).toHaveAttribute('type', 'password');
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();
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