Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Copy Markdown
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
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
)=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.fulfill({ response,json: newJson});
});
};
/**
*MockstheenvironmentsettingsAPIresponsetooverrideAPIkeyssettings.
* @parampage-Playwrightpageinstance
* @paramoverrides-Partialoverridesforuser_api_keys_enabledandorgs_api_keys_enabled
*/
constmockAPIKeysEnvironmentSettings=async(
page: Page,
overrides: Partial<{
user_api_keys_enabled: boolean;
orgs_api_keys_enabled: boolean;
}>,
): Promise<void>=>{
awaitpage.route('*/**/v1/environment*',asyncroute=>{
constresponse=awaitroute.fetch();
constjson=awaitresponse.json();
constnewJson={
...json,
api_keys_settings: {
user_api_keys_enabled: true,
orgs_api_keys_enabled: true,
...overrides,
},
};
awaitroute.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 DownExpand 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();
});
Comment thread
wobsoriano marked this conversation as resolved.

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
25 changes: 18 additions & 7 deletions packages/clerk-js/src/core/clerk.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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