Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .changeset/serious-mugs-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/clerk-js": minor
"@clerk/shared": minor
---

Support granular API keys settings for user and organization profiles
147 changes: 100 additions & 47 deletions integration/tests/machine-auth/component.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

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

const mockAPIKeysEnvironmentSettings = async (
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
) => {
await page.route('*/**/v1/environment*', async route => {
const response = await route.fetch();
const json = await response.json();
const newJson = {
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
await route.fulfill({ response, json: newJson });
});
};
Comment on lines +8 to +28
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

Add explicit return type and JSDoc documentation.

Per coding guidelines, functions should have explicit return types and public/shared functions should have JSDoc documentation.

Apply this diff:

+/**
+ * Mocks the environment settings API response to override API keys settings.
+ * @param page - Playwright page instance
+ * @param overrides - Partial overrides for user_api_keys_enabled and orgs_api_keys_enabled
+ */
 const mockAPIKeysEnvironmentSettings = async (
   page: Page,
   overrides: Partial<{
     user_api_keys_enabled: boolean;
     orgs_api_keys_enabled: boolean;
   }>,
-) => {
+): Promise<void> => {
   await page.route('*/**/v1/environment*', async route => {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mockAPIKeysEnvironmentSettings = async (
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
) => {
await page.route('*/**/v1/environment*', async route => {
const response = await route.fetch();
const json = await response.json();
const newJson = {
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
await route.fulfill({ response, json: newJson });
});
};
/**
* Mocks the environment settings API response to override API keys settings.
* @param page - Playwright page instance
* @param overrides - Partial overrides for user_api_keys_enabled and orgs_api_keys_enabled
*/
const mockAPIKeysEnvironmentSettings = async (
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void> => {
await page.route('*/**/v1/environment*', async route => {
const response = await route.fetch();
const json = await response.json();
const newJson = {
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
await route.fulfill({ response, json: newJson });
});
};
🤖 Prompt for AI Agents
In integration/tests/machine-auth/component.test.ts around lines 8 to 28, the
helper function mockAPIKeysEnvironmentSettings is missing an explicit return
type and JSDoc; add a concise JSDoc block above the function describing its
purpose, parameters (page: Page, overrides: Partial<{ user_api_keys_enabled:
boolean; orgs_api_keys_enabled: boolean; }>), and behavior, and update the
function signature to include an explicit return type of Promise<void>. Ensure
the JSDoc uses standard tags (@param, @returns) and the signature remains
consistent with the existing usage.


testAgainstRunningApps({
withEnv: [appConfigs.envs.withAPIKeys],
withPattern: ['withMachine.next.appRouter'],
Expand Down Expand Up @@ -214,81 +237,111 @@ testAgainstRunningApps({
expect(clipboardText).toBe(secret);
});

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

const fakeMember = u.services.users.createFakeUser();
const member = await u.services.users.createBapiUser(fakeMember);
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.services.clerk.organizations.createOrganizationMembership({
organizationId: fakeOrganization.organization.id,
role: 'org:member',
userId: member.id,
});
// user_api_keys_enabled: false should hide API keys page
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: false });
await u.po.page.goToRelative('/user');
await u.po.userProfile.waitForMounted();
await u.po.page.goToRelative('/user#/api-keys');
await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 });

// user_api_keys_enabled: true should show API keys page
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true });
await page.reload();
await u.po.userProfile.waitForMounted();
await u.po.page.goToRelative('/user#/api-keys');
await expect(u.page.locator('.cl-apiKeys')).toBeVisible({ timeout: 5000 });

await u.page.unrouteAll();
});

test('OrganizationProfile API keys page 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();

// orgs_api_keys_enabled: false should hide API keys page
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: false });
await u.po.page.goToRelative('/organization-profile');
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 });

// orgs_api_keys_enabled: true should show API keys page
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: true });
await page.reload();
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
await expect(u.page.locator('.cl-apiKeys')).toBeVisible({ timeout: 5000 });

await u.page.unrouteAll();
});

test('standalone API keys component in user context based on user_api_keys_enabled', 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: fakeMember.email, password: fakeMember.password });
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
await u.po.expect.toBeSignedIn();

// user_api_keys_enabled: false should prevent standalone component from rendering
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: false });

let apiKeysRequestWasMade = false;
u.page.on('request', request => {
if (request.url().includes('/api_keys')) {
apiKeysRequestWasMade = true;
}
await u.page.route('**/api_keys*', async route => {
apiKeysRequestWasMade = true;
await route.abort();
});

// Check that standalone component is not rendered
await u.po.page.goToRelative('/api-keys');
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });

// Check that page is not rendered in OrganizationProfile
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });

expect(apiKeysRequestWasMade).toBe(false);

await fakeMember.deleteIfExists();
// user_api_keys_enabled: true should allow standalone component to render
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true });
await page.reload();
await u.po.apiKeys.waitForMounted();
await expect(u.page.locator('.cl-apiKeys-root')).toBeVisible();

await u.page.unrouteAll();
});

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

const fakeViewer = u.services.users.createFakeUser();
const viewer = await u.services.users.createBapiUser(fakeViewer);

await u.services.clerk.organizations.createOrganizationMembership({
organizationId: fakeOrganization.organization.id,
role: 'org:viewer',
userId: viewer.id,
});

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeViewer.email, password: fakeViewer.password });
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password });
await u.po.expect.toBeSignedIn();

// orgs_api_keys_enabled: false should prevent standalone component from rendering in org context
await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: false });

let apiKeysRequestWasMade = false;
u.page.on('request', request => {
if (request.url().includes('/api_keys')) {
apiKeysRequestWasMade = true;
}
await u.page.route('**/api_keys*', async route => {
apiKeysRequestWasMade = true;
await route.abort();
});

// Check that standalone component is rendered and user can read API keys
await u.po.page.goToRelative('/api-keys');
await u.po.apiKeys.waitForMounted();
await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden();
await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden();

// Check that page is rendered in OrganizationProfile and user can read API keys
await u.po.page.goToRelative('/organization-profile#/organization-api-keys');
await expect(u.page.locator('.cl-apiKeys')).toBeVisible();
await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden();
await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden();
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
expect(apiKeysRequestWasMade).toBe(false);

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

await fakeViewer.deleteIfExists();
await u.page.unrouteAll();
});
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,15 @@ import type { MountComponentRenderer } from '../ui/Components';
import {
ALLOWED_PROTOCOLS,
buildURL,
canViewOrManageAPIKeys,
completeSignUpFlow,
createAllowedRedirectOrigins,
createBeforeUnloadTracker,
createPageLifecycle,
disabledAllAPIKeysFeatures,
disabledAllBillingFeatures,
disabledAPIKeysFeature,
disabledOrganizationAPIKeysFeature,
disabledOrganizationsFeature,
disabledUserAPIKeysFeature,
errorThrower,
generateSignatureWithBase,
generateSignatureWithCoinbaseWallet,
Expand Down Expand Up @@ -179,7 +180,8 @@ const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizat
const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing';
const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled';
const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled';
const CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE = 'cannot_render_api_keys_org_unauthorized';
const CANNOT_RENDER_API_KEYS_USER_DISABLED_ERROR_CODE = 'cannot_render_api_keys_user_disabled';
const CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE = 'cannot_render_api_keys_org_disabled';
const defaultOptions: ClerkOptions = {
polling: true,
standardBrowser: true,
Expand Down Expand Up @@ -1233,7 +1235,7 @@ export class Clerk implements ClerkInterface {

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

if (disabledAPIKeysFeature(this, this.environment)) {
if (disabledAllAPIKeysFeatures(this, this.environment)) {
if (this.#instanceType === 'development') {
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponent, {
code: CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE,
Expand All @@ -1242,10 +1244,19 @@ export class Clerk implements ClerkInterface {
return;
}

if (this.organization && !canViewOrManageAPIKeys(this)) {
if (this.organization && disabledOrganizationAPIKeysFeature(this, this.environment)) {
if (this.#instanceType === 'development') {
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenUnauthorized, {
code: CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE,
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenDisabled, {
code: CANNOT_RENDER_API_KEYS_ORG_DISABLED_ERROR_CODE,
});
}
return;
}

if (disabledUserAPIKeysFeature(this, this.environment)) {
if (this.#instanceType === 'development') {
throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForUserWhenDisabled, {
code: CANNOT_RENDER_API_KEYS_USER_DISABLED_ERROR_CODE,
});
}
return;
Expand Down
10 changes: 7 additions & 3 deletions packages/clerk-js/src/core/resources/APIKeySettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { BaseResource } from './internal';
* @internal
*/
export class APIKeySettings extends BaseResource implements APIKeysSettingsResource {
enabled: boolean = false;
public user_api_keys_enabled: boolean = false;
public orgs_api_keys_enabled: boolean = false;

public constructor(data: APIKeysSettingsJSON | APIKeysSettingsJSONSnapshot | null = null) {
super();

this.fromJSON(data);
}

Expand All @@ -18,14 +20,16 @@ export class APIKeySettings extends BaseResource implements APIKeysSettingsResou
return this;
}

this.enabled = this.withDefault(data.enabled, false);
this.user_api_keys_enabled = data.user_api_keys_enabled;
this.orgs_api_keys_enabled = data.orgs_api_keys_enabled;

return this;
}

public __internal_toSnapshot(): APIKeysSettingsJSONSnapshot {
return {
enabled: this.enabled,
user_api_keys_enabled: this.user_api_keys_enabled,
orgs_api_keys_enabled: this.orgs_api_keys_enabled,
} as APIKeysSettingsJSONSnapshot;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ describe('Environment', () => {

expect(environment).toMatchObject({
apiKeysSettings: expect.objectContaining({
enabled: false,
orgs_api_keys_enabled: false,
user_api_keys_enabled: false,
pathRoot: '',
}),
authConfig: expect.objectContaining({
Expand Down Expand Up @@ -53,7 +54,8 @@ describe('Environment', () => {
object: 'environment',
id: '',
api_keys_settings: {
enabled: false,
orgs_api_keys_enabled: false,
user_api_keys_enabled: false,
id: undefined,
path_root: '',
},
Expand Down Expand Up @@ -578,7 +580,8 @@ describe('Environment', () => {
expect(environment.__internal_toSnapshot()).toMatchObject({
object: 'environment',
api_keys_settings: expect.objectContaining({
enabled: false,
orgs_api_keys_enabled: false,
user_api_keys_enabled: false,
}),
auth_config: expect.objectContaining({
single_session_mode: true,
Expand Down
8 changes: 5 additions & 3 deletions packages/clerk-js/src/core/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ const warnings = {
cannotOpenSignInOrSignUp:
'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.',
cannotRenderAPIKeysComponent:
'The <APIKeys/> component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.',
cannotRenderAPIKeysComponentForOrgWhenUnauthorized:
'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.',
'The <APIKeys/> component cannot be rendered when API keys are disabled. Since API keys are disabled, this is no-op.',
cannotRenderAPIKeysComponentForUserWhenDisabled:
'The <APIKeys/> component cannot be rendered when user API keys are disabled. Since user API keys are disabled, this is no-op.',
cannotRenderAPIKeysComponentForOrgWhenDisabled:
'The <APIKeys/> component cannot be rendered when organization API keys are disabled. Since organization API keys are disabled, this is no-op.',
};

type SerializableWarnings = Serializable<typeof warnings>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const OrganizationProfileNavbar = (
props: React.PropsWithChildren<Pick<PropsOfComponent<typeof NavBar>, 'contentRef'>>,
) => {
const { organization } = useOrganization();
const { pages } = useOrganizationProfileContext();
const { apiKeysProps, pages } = useOrganizationProfileContext();

const allowMembersRoute = useProtect(
has =>
Expand All @@ -39,7 +39,8 @@ export const OrganizationProfileNavbar = (
r =>
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING ||
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING && allowBillingRoutes),
);
)
.filter(r => r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.API_KEYS || !apiKeysProps?.hide);
if (!organization) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ const OrganizationPaymentAttemptPage = lazy(() =>
);

export const OrganizationProfileRoutes = () => {
const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot, isApiKeysPageRoot, shouldShowBilling } =
useOrganizationProfileContext();
const {
pages,
isMembersPageRoot,
isGeneralPageRoot,
isBillingPageRoot,
isApiKeysPageRoot,
shouldShowBilling,
apiKeysProps,
} = useOrganizationProfileContext();
const { apiKeysSettings, commerceSettings } = useEnvironment();

const customPageRoutesWithContents = pages.contents?.map((customPage, index) => {
Expand Down Expand Up @@ -117,7 +124,7 @@ export const OrganizationProfileRoutes = () => {
</Route>
</Protect>
) : null}
{apiKeysSettings.enabled && (
{apiKeysSettings.orgs_api_keys_enabled && !apiKeysProps?.hide && (
<Protect
condition={has =>
has({ permission: 'org:sys_api_keys:read' }) || has({ permission: 'org:sys_api_keys:manage' })
Expand Down
Loading