Skip to content

Commit d88ea99

Browse files
chore(clerk-js): Remove secret key column in API keys component (#7107)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 5536429 commit d88ea99

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+867
-431
lines changed

.changeset/chilly-boxes-warn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/localizations": minor
3+
---
4+
5+
Added localization entry for the API key copy modal component.

.changeset/heavy-books-protect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
---
4+
5+
Replaced the persistent key column in the API keys table with a one-time modal that displays the secret immediately after creation.

integration/testUtils/usersService.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,9 @@ export const createUserService = (clerkClient: ClerkClient) => {
204204
secondsUntilExpiration: TWENTY_MINUTES,
205205
});
206206

207-
const { secret } = await clerkClient.apiKeys.getSecret(apiKey.id);
208-
209207
return {
210208
apiKey,
211-
secret,
209+
secret: apiKey.secret ?? '',
212210
revoke: () => clerkClient.apiKeys.revoke({ apiKeyId: apiKey.id, revocationReason: 'For testing purposes' }),
213211
} satisfies FakeAPIKey;
214212
},

integration/tests/machine-auth/component.test.ts

Lines changed: 31 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ testAgainstRunningApps({
4343
await u.po.apiKeys.selectExpiration('1d');
4444
await u.po.apiKeys.clickSaveButton();
4545

46+
// Close copy modal
47+
await u.po.apiKeys.waitForCopyModalOpened();
48+
await u.po.apiKeys.clickCopyAndCloseButton();
49+
await u.po.apiKeys.waitForCopyModalClosed();
4650
await u.po.apiKeys.waitForFormClosed();
4751

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

59+
// Wait and close copy modal
60+
await u.po.apiKeys.waitForCopyModalOpened();
61+
await u.po.apiKeys.clickCopyAndCloseButton();
62+
await u.po.apiKeys.waitForCopyModalClosed();
63+
await u.po.apiKeys.waitForFormClosed();
64+
5565
// Check if both API keys are created
56-
await expect(u.page.locator('.cl-apiKeysTable .cl-tableRow')).toHaveCount(2);
66+
await expect(u.page.locator('.cl-apiKeysTable .cl-tableBody .cl-tableRow')).toHaveCount(2);
5767
});
5868

5969
test('can revoke api keys', async ({ page, context }) => {
@@ -74,6 +84,11 @@ testAgainstRunningApps({
7484
await u.po.apiKeys.typeName(apiKeyName);
7585
await u.po.apiKeys.selectExpiration('1d');
7686
await u.po.apiKeys.clickSaveButton();
87+
88+
// Wait and close copy modal
89+
await u.po.apiKeys.waitForCopyModalOpened();
90+
await u.po.apiKeys.clickCopyAndCloseButton();
91+
await u.po.apiKeys.waitForCopyModalClosed();
7792
await u.po.apiKeys.waitForFormClosed();
7893

7994
// Retrieve API key
@@ -97,7 +112,7 @@ testAgainstRunningApps({
97112
await expect(table.locator('.cl-tableRow', { hasText: apiKeyName })).toHaveCount(0);
98113
});
99114

100-
test('can copy api key secret', async ({ page, context }) => {
115+
test('can copy api key secret after creation', async ({ page, context }) => {
101116
const u = createTestUtils({ app, page, context });
102117
await u.po.signIn.goTo();
103118
await u.po.signIn.waitForMounted();
@@ -109,71 +124,30 @@ testAgainstRunningApps({
109124

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

112-
// Create API key
127+
// Create API key and capture the secret from the response
128+
const createResponsePromise = page.waitForResponse(
129+
response => response.url().includes('/api_keys') && response.request().method() === 'POST',
130+
);
113131
await u.po.apiKeys.clickAddButton();
114132
await u.po.apiKeys.waitForFormOpened();
115133
await u.po.apiKeys.typeName(apiKeyName);
116134
await u.po.apiKeys.selectExpiration('1d');
117135
await u.po.apiKeys.clickSaveButton();
118-
await u.po.apiKeys.waitForFormClosed();
119136

120-
const responsePromise = page.waitForResponse(
121-
response => response.url().includes('/secret') && response.request().method() === 'GET',
122-
);
123-
124-
// Copy API key
125-
const table = u.page.locator('.cl-apiKeysTable');
126-
const row = table.locator('.cl-tableRow', { hasText: apiKeyName });
127-
await row.waitFor({ state: 'attached' });
128-
await row.locator('.cl-apiKeysCopyButton').click();
137+
const createResponse = await createResponsePromise;
138+
const secret = (await createResponse.json()).secret;
129139

130-
// Read clipboard contents
131-
const data = await (await responsePromise).json();
140+
// Copy secret via modal and verify clipboard contents
141+
// Wait and close copy modal
142+
await u.po.apiKeys.waitForCopyModalOpened();
132143
await context.grantPermissions(['clipboard-read']);
133-
const clipboardText = await page.evaluate('navigator.clipboard.readText()');
134-
await context.clearPermissions();
135-
expect(clipboardText).toBe(data.secret);
136-
});
137-
138-
test('can toggle api key secret visibility', async ({ page, context }) => {
139-
const u = createTestUtils({ app, page, context });
140-
await u.po.signIn.goTo();
141-
await u.po.signIn.waitForMounted();
142-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
143-
await u.po.expect.toBeSignedIn();
144-
145-
await u.po.page.goToRelative('/api-keys');
146-
await u.po.apiKeys.waitForMounted();
147-
148-
const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`;
149-
150-
// Create API key
151-
await u.po.apiKeys.clickAddButton();
152-
await u.po.apiKeys.waitForFormOpened();
153-
await u.po.apiKeys.typeName(apiKeyName);
154-
await u.po.apiKeys.selectExpiration('1d');
155-
await u.po.apiKeys.clickSaveButton();
144+
await u.po.apiKeys.clickCopyAndCloseButton();
145+
await u.po.apiKeys.waitForCopyModalClosed();
156146
await u.po.apiKeys.waitForFormClosed();
157147

158-
const responsePromise = page.waitForResponse(
159-
response => response.url().includes('/secret') && response.request().method() === 'GET',
160-
);
161-
162-
// Toggle API key secret visibility
163-
const table = u.page.locator('.cl-apiKeysTable');
164-
const row = table.locator('.cl-tableRow', { hasText: apiKeyName });
165-
await row.waitFor({ state: 'attached' });
166-
await expect(row.locator('input')).toHaveAttribute('type', 'password');
167-
await row.locator('.cl-apiKeysRevealButton').click();
168-
169-
// Verify if secret matches the input value
170-
const data = await (await responsePromise).json();
171-
await expect(row.locator('input')).toHaveAttribute('type', 'text');
172-
await expect(row.locator('input')).toHaveValue(data.secret);
173-
174-
// Toggle visibility off
175-
await row.locator('.cl-apiKeysRevealButton').click();
176-
await expect(row.locator('input')).toHaveAttribute('type', 'password');
148+
const clipboardText = await page.evaluate('navigator.clipboard.readText()');
149+
await context.clearPermissions();
150+
expect(clipboardText).toBe(secret);
177151
});
178152

179153
test('component does not render for orgs when user does not have permissions', async ({ page, context }) => {

packages/clerk-js/src/core/modules/apiKeys/index.ts

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -54,50 +54,32 @@ export class APIKeys implements APIKeysNamespace {
5454
});
5555
}
5656

57-
async getSecret(id: string): Promise<string> {
58-
return BaseResource.clerk
59-
.getFapiClient()
60-
.request<{ secret: string }>({
61-
...(await this.getBaseFapiProxyOptions()),
62-
method: 'GET',
63-
path: `/api_keys/${id}/secret`,
64-
})
65-
.then(res => {
66-
const { secret } = res.payload as unknown as { secret: string };
67-
return secret;
68-
});
69-
}
70-
7157
async create(params: CreateAPIKeyParams): Promise<APIKeyResource> {
72-
const json = (
73-
await BaseResource._fetch<ApiKeyJSON>({
74-
...(await this.getBaseFapiProxyOptions()),
75-
path: '/api_keys',
76-
method: 'POST',
77-
body: JSON.stringify({
78-
type: params.type ?? 'api_key',
79-
name: params.name,
80-
subject: params.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
81-
description: params.description,
82-
seconds_until_expiration: params.secondsUntilExpiration,
83-
}),
84-
})
85-
)?.response as ApiKeyJSON;
58+
const json = (await BaseResource._fetch<ApiKeyJSON>({
59+
...(await this.getBaseFapiProxyOptions()),
60+
path: '/api_keys',
61+
method: 'POST',
62+
body: JSON.stringify({
63+
type: params.type ?? 'api_key',
64+
name: params.name,
65+
subject: params.subject ?? BaseResource.clerk.organization?.id ?? BaseResource.clerk.user?.id ?? '',
66+
description: params.description,
67+
seconds_until_expiration: params.secondsUntilExpiration,
68+
}),
69+
})) as unknown as ApiKeyJSON;
8670

8771
return new APIKey(json);
8872
}
8973

9074
async revoke(params: RevokeAPIKeyParams): Promise<APIKeyResource> {
91-
const json = (
92-
await BaseResource._fetch<ApiKeyJSON>({
93-
...(await this.getBaseFapiProxyOptions()),
94-
method: 'POST',
95-
path: `/api_keys/${params.apiKeyID}/revoke`,
96-
body: JSON.stringify({
97-
revocation_reason: params.revocationReason,
98-
}),
99-
})
100-
)?.response as ApiKeyJSON;
75+
const json = (await BaseResource._fetch<ApiKeyJSON>({
76+
...(await this.getBaseFapiProxyOptions()),
77+
method: 'POST',
78+
path: `/api_keys/${params.apiKeyID}/revoke`,
79+
body: JSON.stringify({
80+
revocation_reason: params.revocationReason,
81+
}),
82+
})) as unknown as ApiKeyJSON;
10183

10284
return new APIKey(json);
10385
}

packages/clerk-js/src/core/resources/APIKey.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class APIKey extends BaseResource implements APIKeyResource {
1818
expiration!: Date | null;
1919
createdBy!: string | null;
2020
description!: string | null;
21+
secret?: string;
2122
lastUsedAt!: Date | null;
2223
createdAt!: Date;
2324
updatedAt!: Date;
@@ -44,6 +45,7 @@ export class APIKey extends BaseResource implements APIKeyResource {
4445
this.expiration = data.expiration ? unixEpochToDate(data.expiration) : null;
4546
this.createdBy = data.created_by;
4647
this.description = data.description;
48+
this.secret = data.secret;
4749
this.lastUsedAt = data.last_used_at ? unixEpochToDate(data.last_used_at) : null;
4850
this.updatedAt = unixEpochToDate(data.updated_at);
4951
this.createdAt = unixEpochToDate(data.created_at);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
3+
import { Modal } from '@/ui/elements/Modal';
4+
import type { ThemableCssProp } from '@/ui/styledSystem';
5+
6+
type ApiKeyModalProps = React.ComponentProps<typeof Modal> & {
7+
modalRoot?: React.MutableRefObject<HTMLElement | null>;
8+
};
9+
10+
/**
11+
* Container styles for modals rendered within a custom portal root (e.g., UserProfile or OrganizationProfile).
12+
* When a modalRoot is provided, the modal is scoped to that container rather than the document root,
13+
* requiring different positioning (absolute instead of fixed) and backdrop styling.
14+
*/
15+
const getScopedPortalContainerStyles = (modalRoot?: React.MutableRefObject<HTMLElement | null>): ThemableCssProp => {
16+
return [
17+
{ alignItems: 'center' },
18+
modalRoot
19+
? t => ({
20+
position: 'absolute',
21+
right: 0,
22+
bottom: 0,
23+
backgroundColor: 'inherit',
24+
backdropFilter: `blur(${t.sizes.$2})`,
25+
display: 'flex',
26+
justifyContent: 'center',
27+
minHeight: '100%',
28+
height: '100%',
29+
width: '100%',
30+
borderRadius: t.radii.$lg,
31+
})
32+
: {},
33+
];
34+
};
35+
36+
export const ApiKeyModal = ({ modalRoot, containerSx, ...modalProps }: ApiKeyModalProps) => {
37+
return (
38+
<Modal
39+
{...modalProps}
40+
portalRoot={modalRoot}
41+
containerSx={[getScopedPortalContainerStyles(modalRoot), containerSx]}
42+
/>
43+
);
44+
};

packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error';
22
import { useClerk, useOrganization, useUser } from '@clerk/shared/react';
33
import type { CreateAPIKeyParams } from '@clerk/shared/types';
44
import { lazy, useState } from 'react';
5+
import { useSWRConfig } from 'swr';
56
import useSWRMutation from 'swr/mutation';
67

78
import { useProtect } from '@/ui/common';
@@ -42,6 +43,12 @@ const RevokeAPIKeyConfirmationModal = lazy(() =>
4243
})),
4344
);
4445

46+
const CopyApiKeyModal = lazy(() =>
47+
import(/* webpackChunkName: "copy-api-key-modal"*/ './CopyApiKeyModal').then(module => ({
48+
default: module.CopyApiKeyModal,
49+
})),
50+
);
51+
4552
export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => {
4653
const isOrg = isOrganizationId(subject);
4754
const canReadAPIKeys = useProtect({ permission: 'org:sys_api_keys:read' });
@@ -61,23 +68,34 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
6168
cacheKey,
6269
} = useApiKeys({ subject, perPage, enabled: isOrg ? canReadAPIKeys : true });
6370
const card = useCardState();
64-
const { trigger: createApiKey, isMutating } = useSWRMutation(cacheKey, (_, { arg }: { arg: CreateAPIKeyParams }) =>
65-
clerk.apiKeys.create(arg),
71+
const clerk = useClerk();
72+
const {
73+
data: createdApiKey,
74+
trigger: createApiKey,
75+
isMutating,
76+
} = useSWRMutation(
77+
{
78+
...cacheKey,
79+
action: 'create',
80+
},
81+
(_key, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg),
6682
);
83+
const { mutate: mutateApiKeys } = useSWRConfig();
6784
const { t } = useLocalizations();
68-
const clerk = useClerk();
6985
const [isRevokeModalOpen, setIsRevokeModalOpen] = useState(false);
7086
const [selectedApiKeyId, setSelectedApiKeyId] = useState('');
7187
const [selectedApiKeyName, setSelectedApiKeyName] = useState('');
88+
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
7289

73-
const handleCreateApiKey = async (params: OnCreateParams, closeCardFn: () => void) => {
90+
const handleCreateApiKey = async (params: OnCreateParams) => {
7491
try {
7592
await createApiKey({
7693
...params,
7794
subject,
7895
});
79-
closeCardFn();
96+
void mutateApiKeys(cacheKey);
8097
card.setError(undefined);
98+
setIsCopyModalOpen(true);
8199
} catch (err: any) {
82100
if (isClerkAPIResponseError(err)) {
83101
if (err.status === 409) {
@@ -147,7 +165,17 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
147165
</Action.Card>
148166
</Flex>
149167
</Action.Open>
168+
169+
<CopyApiKeyModal
170+
isOpen={isCopyModalOpen}
171+
onOpen={() => setIsCopyModalOpen(true)}
172+
onClose={() => setIsCopyModalOpen(false)}
173+
apiKeyName={createdApiKey?.name ?? ''}
174+
apiKeySecret={createdApiKey?.secret ?? ''}
175+
modalRoot={revokeModalRoot}
176+
/>
150177
</Action.Root>
178+
151179
<ApiKeysTable
152180
rows={apiKeys}
153181
isLoading={isLoading}
@@ -164,6 +192,8 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr
164192
rowInfo={{ allRowsCount: itemCount, startingRow, endingRow }}
165193
/>
166194
)}
195+
196+
{/* Modals */}
167197
<RevokeAPIKeyConfirmationModal
168198
subject={subject}
169199
isOpen={isRevokeModalOpen}

0 commit comments

Comments
 (0)