Skip to content

Commit c413433

Browse files
authored
chore(clerk-js,shared): Support granular API keys settings (#7179)
1 parent a95fece commit c413433

File tree

16 files changed

+331
-89
lines changed

16 files changed

+331
-89
lines changed

.changeset/serious-mugs-flash.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clerk/clerk-js": minor
3+
"@clerk/shared": minor
4+
---
5+
6+
Support granular API keys settings for user and organization profiles

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

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1+
import type { Page } from '@playwright/test';
12
import { expect, test } from '@playwright/test';
23

34
import { appConfigs } from '../../presets';
45
import type { FakeOrganization, FakeUser } from '../../testUtils';
56
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
67

8+
const mockAPIKeysEnvironmentSettings = async (
9+
page: Page,
10+
overrides: Partial<{
11+
user_api_keys_enabled: boolean;
12+
orgs_api_keys_enabled: boolean;
13+
}>,
14+
) => {
15+
await page.route('*/**/v1/environment*', async route => {
16+
const response = await route.fetch();
17+
const json = await response.json();
18+
const newJson = {
19+
...json,
20+
api_keys_settings: {
21+
user_api_keys_enabled: true,
22+
orgs_api_keys_enabled: true,
23+
...overrides,
24+
},
25+
};
26+
await route.fulfill({ response, json: newJson });
27+
});
28+
};
29+
730
testAgainstRunningApps({
831
withEnv: [appConfigs.envs.withAPIKeys],
932
withPattern: ['withMachine.next.appRouter'],
@@ -214,81 +237,111 @@ testAgainstRunningApps({
214237
expect(clipboardText).toBe(secret);
215238
});
216239

217-
test('component does not render for orgs when user does not have permissions', async ({ page, context }) => {
240+
test('UserProfile API keys page visibility', async ({ page, context }) => {
218241
const u = createTestUtils({ app, page, context });
219242

220-
const fakeMember = u.services.users.createFakeUser();
221-
const member = await u.services.users.createBapiUser(fakeMember);
243+
await u.po.signIn.goTo();
244+
await u.po.signIn.waitForMounted();
245+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
246+
await u.po.expect.toBeSignedIn();
222247

223-
await u.services.clerk.organizations.createOrganizationMembership({
224-
organizationId: fakeOrganization.organization.id,
225-
role: 'org:member',
226-
userId: member.id,
227-
});
248+
// user_api_keys_enabled: false should hide API keys page
249+
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: false });
250+
await u.po.page.goToRelative('/user');
251+
await u.po.userProfile.waitForMounted();
252+
await u.po.page.goToRelative('/user#/api-keys');
253+
await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 });
254+
255+
// user_api_keys_enabled: true should show API keys page
256+
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true });
257+
await page.reload();
258+
await u.po.userProfile.waitForMounted();
259+
await u.po.page.goToRelative('/user#/api-keys');
260+
await expect(u.page.locator('.cl-apiKeys')).toBeVisible({ timeout: 5000 });
261+
262+
await u.page.unrouteAll();
263+
});
264+
265+
test('OrganizationProfile API keys page visibility', async ({ page, context }) => {
266+
const u = createTestUtils({ app, page, context });
267+
268+
await u.po.signIn.goTo();
269+
await u.po.signIn.waitForMounted();
270+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
271+
await u.po.expect.toBeSignedIn();
272+
273+
// orgs_api_keys_enabled: false should hide API keys page
274+
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: false });
275+
await u.po.page.goToRelative('/organization-profile');
276+
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
277+
await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 });
278+
279+
// orgs_api_keys_enabled: true should show API keys page
280+
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: true });
281+
await page.reload();
282+
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
283+
await expect(u.page.locator('.cl-apiKeys')).toBeVisible({ timeout: 5000 });
284+
285+
await u.page.unrouteAll();
286+
});
287+
288+
test('standalone API keys component in user context based on user_api_keys_enabled', async ({ page, context }) => {
289+
const u = createTestUtils({ app, page, context });
228290

229291
await u.po.signIn.goTo();
230292
await u.po.signIn.waitForMounted();
231-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeMember.email, password: fakeMember.password });
293+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
232294
await u.po.expect.toBeSignedIn();
233295

296+
// user_api_keys_enabled: false should prevent standalone component from rendering
297+
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: false });
298+
234299
let apiKeysRequestWasMade = false;
235-
u.page.on('request', request => {
236-
if (request.url().includes('/api_keys')) {
237-
apiKeysRequestWasMade = true;
238-
}
300+
await u.page.route('**/api_keys*', async route => {
301+
apiKeysRequestWasMade = true;
302+
await route.abort();
239303
});
240304

241-
// Check that standalone component is not rendered
242305
await u.po.page.goToRelative('/api-keys');
243306
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
244-
245-
// Check that page is not rendered in OrganizationProfile
246-
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
247-
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
248-
249307
expect(apiKeysRequestWasMade).toBe(false);
250308

251-
await fakeMember.deleteIfExists();
309+
// user_api_keys_enabled: true should allow standalone component to render
310+
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true });
311+
await page.reload();
312+
await u.po.apiKeys.waitForMounted();
313+
await expect(u.page.locator('.cl-apiKeys-root')).toBeVisible();
314+
315+
await u.page.unrouteAll();
252316
});
253317

254-
test('user with read permission can view API keys but not manage them', async ({ page, context }) => {
318+
test('standalone API keys component in org context based on orgs_api_keys_enabled', async ({ page, context }) => {
255319
const u = createTestUtils({ app, page, context });
256320

257-
const fakeViewer = u.services.users.createFakeUser();
258-
const viewer = await u.services.users.createBapiUser(fakeViewer);
259-
260-
await u.services.clerk.organizations.createOrganizationMembership({
261-
organizationId: fakeOrganization.organization.id,
262-
role: 'org:viewer',
263-
userId: viewer.id,
264-
});
265-
266321
await u.po.signIn.goTo();
267322
await u.po.signIn.waitForMounted();
268-
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeViewer.email, password: fakeViewer.password });
323+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
269324
await u.po.expect.toBeSignedIn();
270325

326+
// orgs_api_keys_enabled: false should prevent standalone component from rendering in org context
327+
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: false });
328+
271329
let apiKeysRequestWasMade = false;
272-
u.page.on('request', request => {
273-
if (request.url().includes('/api_keys')) {
274-
apiKeysRequestWasMade = true;
275-
}
330+
await u.page.route('**/api_keys*', async route => {
331+
apiKeysRequestWasMade = true;
332+
await route.abort();
276333
});
277334

278-
// Check that standalone component is rendered and user can read API keys
279335
await u.po.page.goToRelative('/api-keys');
280-
await u.po.apiKeys.waitForMounted();
281-
await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden();
282-
await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden();
283-
284-
// Check that page is rendered in OrganizationProfile and user can read API keys
285-
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
286-
await expect(u.page.locator('.cl-apiKeys')).toBeVisible();
287-
await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden();
288-
await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden();
336+
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
337+
expect(apiKeysRequestWasMade).toBe(false);
289338

290-
expect(apiKeysRequestWasMade).toBe(true);
339+
// orgs_api_keys_enabled: true should allow standalone component to render in org context
340+
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: true });
341+
await page.reload();
342+
await u.po.apiKeys.waitForMounted();
343+
await expect(u.page.locator('.cl-apiKeys-root')).toBeVisible();
291344

292-
await fakeViewer.deleteIfExists();
345+
await u.page.unrouteAll();
293346
});
294347
});

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,15 @@ import type { MountComponentRenderer } from '../ui/Components';
102102
import {
103103
ALLOWED_PROTOCOLS,
104104
buildURL,
105-
canViewOrManageAPIKeys,
106105
completeSignUpFlow,
107106
createAllowedRedirectOrigins,
108107
createBeforeUnloadTracker,
109108
createPageLifecycle,
109+
disabledAllAPIKeysFeatures,
110110
disabledAllBillingFeatures,
111-
disabledAPIKeysFeature,
111+
disabledOrganizationAPIKeysFeature,
112112
disabledOrganizationsFeature,
113+
disabledUserAPIKeysFeature,
113114
errorThrower,
114115
generateSignatureWithBase,
115116
generateSignatureWithCoinbaseWallet,
@@ -179,7 +180,8 @@ const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizat
179180
const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing';
180181
const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled';
181182
const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled';
182-
const CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE = 'cannot_render_api_keys_org_unauthorized';
183+
const CANNOT_RENDER_API_KEYS_USER_DISABLED_ERROR_CODE = 'cannot_render_api_keys_user_disabled';
184+
const CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE = 'cannot_render_api_keys_org_disabled';
183185
const defaultOptions: ClerkOptions = {
184186
polling: true,
185187
standardBrowser: true,
@@ -1233,7 +1235,7 @@ export class Clerk implements ClerkInterface {
12331235

12341236
logger.warnOnce('Clerk: <APIKeys /> component is in early access and not yet recommended for production use.');
12351237

1236-
if (disabledAPIKeysFeature(this, this.environment)) {
1238+
if (disabledAllAPIKeysFeatures(this, this.environment)) {
12371239
if (this.#instanceType === 'development') {
12381240
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponent, {
12391241
code: CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE,
@@ -1242,10 +1244,19 @@ export class Clerk implements ClerkInterface {
12421244
return;
12431245
}
12441246

1245-
if (this.organization && !canViewOrManageAPIKeys(this)) {
1247+
if (this.organization && disabledOrganizationAPIKeysFeature(this, this.environment)) {
12461248
if (this.#instanceType === 'development') {
1247-
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenUnauthorized, {
1248-
code: CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE,
1249+
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenDisabled, {
1250+
code: CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE,
1251+
});
1252+
}
1253+
return;
1254+
}
1255+
1256+
if (disabledUserAPIKeysFeature(this, this.environment)) {
1257+
if (this.#instanceType === 'development') {
1258+
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForUserWhenDisabled, {
1259+
code: CANNOT_RENDER_API_KEYS_USER_DISABLED_ERROR_CODE,
12491260
});
12501261
}
12511262
return;

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { BaseResource } from './internal';
66
* @internal
77
*/
88
export class APIKeySettings extends BaseResource implements APIKeysSettingsResource {
9-
enabled: boolean = false;
9+
public user_api_keys_enabled: boolean = false;
10+
public orgs_api_keys_enabled: boolean = false;
1011

1112
public constructor(data: APIKeysSettingsJSON | APIKeysSettingsJSONSnapshot | null = null) {
1213
super();
14+
1315
this.fromJSON(data);
1416
}
1517

@@ -18,14 +20,16 @@ export class APIKeySettings extends BaseResource implements APIKeysSettingsResou
1820
return this;
1921
}
2022

21-
this.enabled = this.withDefault(data.enabled, false);
23+
this.user_api_keys_enabled = data.user_api_keys_enabled;
24+
this.orgs_api_keys_enabled = data.orgs_api_keys_enabled;
2225

2326
return this;
2427
}
2528

2629
public __internal_toSnapshot(): APIKeysSettingsJSONSnapshot {
2730
return {
28-
enabled: this.enabled,
31+
user_api_keys_enabled: this.user_api_keys_enabled,
32+
orgs_api_keys_enabled: this.orgs_api_keys_enabled,
2933
} as APIKeysSettingsJSONSnapshot;
3034
}
3135
}

packages/clerk-js/src/core/resources/__tests__/Environment.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ describe('Environment', () => {
99

1010
expect(environment).toMatchObject({
1111
apiKeysSettings: expect.objectContaining({
12-
enabled: false,
12+
orgs_api_keys_enabled: false,
13+
user_api_keys_enabled: false,
1314
pathRoot: '',
1415
}),
1516
authConfig: expect.objectContaining({
@@ -53,7 +54,8 @@ describe('Environment', () => {
5354
object: 'environment',
5455
id: '',
5556
api_keys_settings: {
56-
enabled: false,
57+
orgs_api_keys_enabled: false,
58+
user_api_keys_enabled: false,
5759
id: undefined,
5860
path_root: '',
5961
},
@@ -578,7 +580,8 @@ describe('Environment', () => {
578580
expect(environment.__internal_toSnapshot()).toMatchObject({
579581
object: 'environment',
580582
api_keys_settings: expect.objectContaining({
581-
enabled: false,
583+
orgs_api_keys_enabled: false,
584+
user_api_keys_enabled: false,
582585
}),
583586
auth_config: expect.objectContaining({
584587
single_session_mode: true,

packages/clerk-js/src/core/warnings.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ const warnings = {
4646
cannotOpenSignInOrSignUp:
4747
'The SignIn or SignUp modals do not render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, this is no-op.',
4848
cannotRenderAPIKeysComponent:
49-
'The <APIKeys/> component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.',
50-
cannotRenderAPIKeysComponentForOrgWhenUnauthorized:
51-
'The <APIKeys/> component cannot be rendered for an organization unless a user has the required permissions. Since the user does not have the necessary permissions, this is no-op.',
49+
'The <APIKeys/> component cannot be rendered when API keys are disabled. Since API keys are disabled, this is no-op.',
50+
cannotRenderAPIKeysComponentForUserWhenDisabled:
51+
'The <APIKeys/> component cannot be rendered when user API keys are disabled. Since user API keys are disabled, this is no-op.',
52+
cannotRenderAPIKeysComponentForOrgWhenDisabled:
53+
'The <APIKeys/> component cannot be rendered when organization API keys are disabled. Since organization API keys are disabled, this is no-op.',
5254
};
5355

5456
type SerializableWarnings = Serializable<typeof warnings>;

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const OrganizationProfileNavbar = (
1313
props: React.PropsWithChildren<Pick<PropsOfComponent<typeof NavBar>, 'contentRef'>>,
1414
) => {
1515
const { organization } = useOrganization();
16-
const { pages } = useOrganizationProfileContext();
16+
const { apiKeysProps, pages } = useOrganizationProfileContext();
1717

1818
const allowMembersRoute = useProtect(
1919
has =>
@@ -39,7 +39,8 @@ export const OrganizationProfileNavbar = (
3939
r =>
4040
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING ||
4141
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING && allowBillingRoutes),
42-
);
42+
)
43+
.filter(r => r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.API_KEYS || !apiKeysProps?.hide);
4344
if (!organization) {
4445
return null;
4546
}

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,15 @@ const OrganizationPaymentAttemptPage = lazy(() =>
3838
);
3939

4040
export const OrganizationProfileRoutes = () => {
41-
const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot, isApiKeysPageRoot, shouldShowBilling } =
42-
useOrganizationProfileContext();
41+
const {
42+
pages,
43+
isMembersPageRoot,
44+
isGeneralPageRoot,
45+
isBillingPageRoot,
46+
isApiKeysPageRoot,
47+
shouldShowBilling,
48+
apiKeysProps,
49+
} = useOrganizationProfileContext();
4350
const { apiKeysSettings, commerceSettings } = useEnvironment();
4451

4552
const customPageRoutesWithContents = pages.contents?.map((customPage, index) => {
@@ -117,7 +124,7 @@ export const OrganizationProfileRoutes = () => {
117124
</Route>
118125
</Protect>
119126
) : null}
120-
{apiKeysSettings.enabled && (
127+
{apiKeysSettings.orgs_api_keys_enabled && !apiKeysProps?.hide && (
121128
<Protect
122129
condition={has =>
123130
has({ permission: 'org:sys_api_keys:read' }) || has({ permission: 'org:sys_api_keys:manage' })

0 commit comments

Comments
 (0)