Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/serious-mugs-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@clerk/clerk-js": minor
"@clerk/shared": patch
---

Added granular API keys settings for user and organization profiles
145 changes: 105 additions & 40 deletions integration/tests/machine-auth/component.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
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;
show_in_user_profile: boolean;
orgs_api_keys_enabled: boolean;
show_in_org_profile: boolean;
}>,
) => {
await page.route('*/**/v1/environment*', async route => {
const response = await route.fetch();
const json = await response.json();
const newJson = {
...json,
api_keys_settings: {
enabled: true,
user_api_keys_enabled: true,
show_in_user_profile: true,
orgs_api_keys_enabled: true,
show_in_org_profile: true,
...overrides,
},
};
await route.fulfill({ response, json: newJson });
});
};

testAgainstRunningApps({
withEnv: [appConfigs.envs.withAPIKeys],
withPattern: ['withMachine.next.appRouter'],
Expand Down Expand Up @@ -214,81 +242,118 @@ 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 });

// show_in_user_profile: false should hide API keys page
await mockAPIKeysEnvironmentSettings(u.page, { show_in_user_profile: false });
await page.reload();
await u.po.userProfile.waitForMounted();
await u.po.page.goToRelative('/user#/api-keys');
await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 });

// Both enabled should show API keys page
await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true, show_in_user_profile: 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 });
});

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: fakeMember.email, password: fakeMember.password });
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 });

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

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

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: 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;
}
});

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

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

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

expect(apiKeysRequestWasMade).toBe(true);
await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 });
expect(apiKeysRequestWasMade).toBe(false);

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

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

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

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

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

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

this.fromJSON(data);
}

Expand All @@ -19,13 +27,21 @@ export class APIKeySettings extends BaseResource implements APIKeysSettingsResou
}

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

return this;
}

public __internal_toSnapshot(): APIKeysSettingsJSONSnapshot {
return {
enabled: this.enabled,
user_api_keys_enabled: this.user_api_keys_enabled,
show_in_user_profile: this.show_in_user_profile,
orgs_api_keys_enabled: this.orgs_api_keys_enabled,
show_in_org_profile: this.show_in_org_profile,
} as APIKeysSettingsJSONSnapshot;
}
}
8 changes: 5 additions & 3 deletions packages/clerk-js/src/core/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ const warnings = {
cannotOpenSignInOrSignUp:
'The SignIn or SignUp modals do not render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, this is no-op.',
cannotRenderAPIKeysComponent:
'The <APIKeys/> component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.',
cannotRenderAPIKeysComponentForOrgWhenUnauthorized:
'The <APIKeys/> component cannot be rendered for an organization unless a user has the required permissions. Since the user does not have the necessary permissions, this is no-op.',
'The <APIKeys/> component cannot be rendered when API keys are disabled. Since API keys are disabled, this is no-op.',
cannotRenderAPIKeysComponentForUserWhenDisabled:
'The <APIKeys/> component cannot be rendered when user API keys are disabled. Since user API keys are disabled, this is no-op.',
cannotRenderAPIKeysComponentForOrgWhenDisabled:
'The <APIKeys/> component cannot be rendered when organization API keys are disabled. Since organization API keys are disabled, this is no-op.',
};

type SerializableWarnings = Serializable<typeof warnings>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const OrganizationProfileRoutes = () => {
</Route>
</Protect>
) : null}
{apiKeysSettings.enabled && (
{apiKeysSettings.orgs_api_keys_enabled && apiKeysSettings.show_in_org_profile && (
<Protect
condition={has =>
has({ permission: 'org:sys_api_keys:read' }) || has({ permission: 'org:sys_api_keys:manage' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const UserProfileRoutes = () => {
</Switch>
</Route>
) : null}
{apiKeysSettings.enabled && (
{apiKeysSettings.user_api_keys_enabled && apiKeysSettings.show_in_user_profile && (
<Route path={isAPIKeysPageRoot ? undefined : 'api-keys'}>
<Switch>
<Route index>
Expand Down
8 changes: 5 additions & 3 deletions packages/clerk-js/src/ui/utils/createCustomPages.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { CustomPage, EnvironmentResource, LoadedClerk } from '@clerk/shared/types';

import {
canViewOrManageAPIKeys,
disabledAPIKeysFeature,
disabledOrganizationBillingFeature,
disabledOrganizationProfileAPIKeysFeature,
disabledUserBillingFeature,
disabledUserProfileAPIKeysFeature,
isValidUrl,
} from '../../utils';
import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID, USER_PROFILE_NAVBAR_ROUTE_ID } from '../constants';
Expand Down Expand Up @@ -104,7 +104,9 @@ const createCustomPages = (
commerce: organization
? !disabledOrganizationBillingFeature(clerk, environment) && shouldShowBilling
: !disabledUserBillingFeature(clerk, environment) && shouldShowBilling,
apiKeys: !disabledAPIKeysFeature(clerk, environment) && (organization ? canViewOrManageAPIKeys(clerk) : true),
apiKeys: organization
? !disabledOrganizationProfileAPIKeysFeature(clerk, environment)
: !disabledUserProfileAPIKeysFeature(clerk, environment),
});

if (isDevelopmentSDK(clerk)) {
Expand Down
25 changes: 15 additions & 10 deletions packages/clerk-js/src/utils/componentGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,22 @@ export const disabledAllBillingFeatures: ComponentGuard = (_, environment) => {
return disabledUserBillingFeature(_, environment) && disabledOrganizationBillingFeature(_, environment);
};

export const disabledAPIKeysFeature: ComponentGuard = (_, environment) => {
return !environment?.apiKeysSettings?.enabled;
export const disabledUserAPIKeysFeature: ComponentGuard = (_, environment) => {
return !environment?.apiKeysSettings?.user_api_keys_enabled;
};

export const canViewOrManageAPIKeys: ComponentGuard = clerk => {
if (!clerk.session) {
return false;
}
export const disabledOrganizationAPIKeysFeature: ComponentGuard = (_, environment) => {
return !environment?.apiKeysSettings?.orgs_api_keys_enabled;
};

export const disabledAllAPIKeysFeatures: ComponentGuard = (_, environment) => {
return disabledUserAPIKeysFeature(_, environment) && disabledOrganizationAPIKeysFeature(_, environment);
};

export const disabledUserProfileAPIKeysFeature: ComponentGuard = (_, environment) => {
return !environment?.apiKeysSettings?.user_api_keys_enabled || !environment?.apiKeysSettings?.show_in_user_profile;
};

return (
clerk.session.checkAuthorization({ permission: 'org:sys_api_keys:read' }) ||
clerk.session.checkAuthorization({ permission: 'org:sys_api_keys:manage' })
);
export const disabledOrganizationProfileAPIKeysFeature: ComponentGuard = (_, environment) => {
return !environment?.apiKeysSettings?.orgs_api_keys_enabled || !environment?.apiKeysSettings?.show_in_org_profile;
};
Loading
Loading