diff --git a/.changeset/serious-mugs-flash.md b/.changeset/serious-mugs-flash.md new file mode 100644 index 00000000000..2ba730d7276 --- /dev/null +++ b/.changeset/serious-mugs-flash.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": minor +"@clerk/shared": minor +--- + +Support granular API keys settings for user and organization profiles diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index 18be38cd7b8..df3845fd0ed 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -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 }); + }); +}; + testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys], withPattern: ['withMachine.next.appRouter'], @@ -214,81 +237,111 @@ testAgainstRunningApps({ expect(clipboardText).toBe(secret); }); - test('component does not render for orgs when user does not have permissions', async ({ page, context }) => { + test('UserProfile API keys page visibility', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - const fakeMember = u.services.users.createFakeUser(); - const member = await u.services.users.createBapiUser(fakeMember); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); - await u.services.clerk.organizations.createOrganizationMembership({ - organizationId: fakeOrganization.organization.id, - role: 'org:member', - userId: member.id, - }); + // user_api_keys_enabled: false should hide API keys page + await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: false }); + await u.po.page.goToRelative('/user'); + await u.po.userProfile.waitForMounted(); + await u.po.page.goToRelative('/user#/api-keys'); + await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 }); + + // user_api_keys_enabled: true should show API keys page + await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true }); + await page.reload(); + await u.po.userProfile.waitForMounted(); + await u.po.page.goToRelative('/user#/api-keys'); + await expect(u.page.locator('.cl-apiKeys')).toBeVisible({ timeout: 5000 }); + + await u.page.unrouteAll(); + }); + + test('OrganizationProfile API keys page visibility', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + // orgs_api_keys_enabled: false should hide API keys page + await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: false }); + await u.po.page.goToRelative('/organization-profile'); + await u.po.page.goToRelative('/organization-profile#/organization-api-keys'); + await expect(u.page.locator('.cl-apiKeys')).toBeHidden({ timeout: 2000 }); + + // orgs_api_keys_enabled: true should show API keys page + await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: true }); + await page.reload(); + await u.po.page.goToRelative('/organization-profile#/organization-api-keys'); + await expect(u.page.locator('.cl-apiKeys')).toBeVisible({ timeout: 5000 }); + + await u.page.unrouteAll(); + }); + + test('standalone API keys component in user context based on user_api_keys_enabled', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeMember.email, password: fakeMember.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); + // user_api_keys_enabled: false should prevent standalone component from rendering + await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: false }); + let apiKeysRequestWasMade = false; - u.page.on('request', request => { - if (request.url().includes('/api_keys')) { - apiKeysRequestWasMade = true; - } + await u.page.route('**/api_keys*', async route => { + apiKeysRequestWasMade = true; + await route.abort(); }); - // Check that standalone component is not rendered await u.po.page.goToRelative('/api-keys'); await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 }); - - // Check that page is not rendered in OrganizationProfile - await u.po.page.goToRelative('/organization-profile#/organization-api-keys'); - await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 }); - expect(apiKeysRequestWasMade).toBe(false); - await fakeMember.deleteIfExists(); + // user_api_keys_enabled: true should allow standalone component to render + await mockAPIKeysEnvironmentSettings(u.page, { user_api_keys_enabled: true }); + await page.reload(); + await u.po.apiKeys.waitForMounted(); + await expect(u.page.locator('.cl-apiKeys-root')).toBeVisible(); + + await u.page.unrouteAll(); }); - test('user with read permission can view API keys but not manage them', async ({ page, context }) => { + test('standalone API keys component in org context based on orgs_api_keys_enabled', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); - const fakeViewer = u.services.users.createFakeUser(); - const viewer = await u.services.users.createBapiUser(fakeViewer); - - await u.services.clerk.organizations.createOrganizationMembership({ - organizationId: fakeOrganization.organization.id, - role: 'org:viewer', - userId: viewer.id, - }); - await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeViewer.email, password: fakeViewer.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); + // orgs_api_keys_enabled: false should prevent standalone component from rendering in org context + await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: false }); + let apiKeysRequestWasMade = false; - u.page.on('request', request => { - if (request.url().includes('/api_keys')) { - apiKeysRequestWasMade = true; - } + await u.page.route('**/api_keys*', async route => { + apiKeysRequestWasMade = true; + await route.abort(); }); - // Check that standalone component is rendered and user can read API keys await u.po.page.goToRelative('/api-keys'); - await u.po.apiKeys.waitForMounted(); - await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden(); - await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden(); - - // Check that page is rendered in OrganizationProfile and user can read API keys - await u.po.page.goToRelative('/organization-profile#/organization-api-keys'); - await expect(u.page.locator('.cl-apiKeys')).toBeVisible(); - await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden(); - await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden(); + await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 }); + expect(apiKeysRequestWasMade).toBe(false); - expect(apiKeysRequestWasMade).toBe(true); + // orgs_api_keys_enabled: true should allow standalone component to render in org context + await mockAPIKeysEnvironmentSettings(u.page, { orgs_api_keys_enabled: true }); + await page.reload(); + await u.po.apiKeys.waitForMounted(); + await expect(u.page.locator('.cl-apiKeys-root')).toBeVisible(); - await fakeViewer.deleteIfExists(); + await u.page.unrouteAll(); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 72213936304..b59a646623a 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -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, @@ -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, @@ -1233,7 +1235,7 @@ export class Clerk implements ClerkInterface { logger.warnOnce('Clerk: 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, @@ -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; diff --git a/packages/clerk-js/src/core/resources/APIKeySettings.ts b/packages/clerk-js/src/core/resources/APIKeySettings.ts index 59997864544..52d00e6ad50 100644 --- a/packages/clerk-js/src/core/resources/APIKeySettings.ts +++ b/packages/clerk-js/src/core/resources/APIKeySettings.ts @@ -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); } @@ -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; } } diff --git a/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts b/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts index c1587669887..b00f4a99078 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Environment.test.ts @@ -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({ @@ -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: '', }, @@ -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, diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 1d001112280..ac0621a4160 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -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 component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.', - cannotRenderAPIKeysComponentForOrgWhenUnauthorized: - 'The 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 component cannot be rendered when API keys are disabled. Since API keys are disabled, this is no-op.', + cannotRenderAPIKeysComponentForUserWhenDisabled: + 'The component cannot be rendered when user API keys are disabled. Since user API keys are disabled, this is no-op.', + cannotRenderAPIKeysComponentForOrgWhenDisabled: + 'The component cannot be rendered when organization API keys are disabled. Since organization API keys are disabled, this is no-op.', }; type SerializableWarnings = Serializable; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx index 5d0ef479f31..4a8a719cb01 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx @@ -13,7 +13,7 @@ export const OrganizationProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, ) => { const { organization } = useOrganization(); - const { pages } = useOrganizationProfileContext(); + const { apiKeysProps, pages } = useOrganizationProfileContext(); const allowMembersRoute = useProtect( has => @@ -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; } diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 62f5a72dfc0..40600731fa3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -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) => { @@ -117,7 +124,7 @@ export const OrganizationProfileRoutes = () => { ) : null} - {apiKeysSettings.enabled && ( + {apiKeysSettings.orgs_api_keys_enabled && !apiKeysProps?.hide && ( has({ permission: 'org:sys_api_keys:read' }) || has({ permission: 'org:sys_api_keys:manage' }) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx index 028cd9bebfd..3df1503334e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx @@ -378,6 +378,92 @@ describe('OrganizationProfile', () => { expect(fixtures.clerk.billing.getStatements).toHaveBeenCalled(); }); }); + + describe('API Keys visibility', () => { + it('does not include API Keys when hide prop is true', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { + name: 'Org1', + permissions: ['org:sys_api_keys:read'], + }, + ], + }); + }); + + fixtures.environment.apiKeysSettings.orgs_api_keys_enabled = true; + props.setProps({ apiKeysProps: { hide: true } }); + + render(, { wrapper }); + await waitFor(() => expect(screen.queryByText('API keys')).toBeNull()); + }); + + it('includes API Keys when hide prop is false and orgs_api_keys_enabled is true', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { + name: 'Org1', + permissions: ['org:sys_api_keys:read'], + }, + ], + }); + }); + + fixtures.environment.apiKeysSettings.orgs_api_keys_enabled = true; + props.setProps({ apiKeysProps: { hide: false } }); + + render(, { wrapper }); + expect(await screen.findByText('API keys')).toBeDefined(); + }); + + it('includes API Keys when hide prop is not set and orgs_api_keys_enabled is true', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { + name: 'Org1', + permissions: ['org:sys_api_keys:read'], + }, + ], + }); + }); + + fixtures.environment.apiKeysSettings.orgs_api_keys_enabled = true; + + render(, { wrapper }); + expect(await screen.findByText('API keys')).toBeDefined(); + }); + + it('does not include API Keys when orgs_api_keys_enabled is false even if hide is false', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { + name: 'Org1', + permissions: ['org:sys_api_keys:read'], + }, + ], + }); + }); + + fixtures.environment.apiKeysSettings.orgs_api_keys_enabled = false; + props.setProps({ apiKeysProps: { hide: false } }); + + render(, { wrapper }); + await waitFor(() => expect(screen.queryByText('API keys')).toBeNull()); + }); + }); + it('removes member nav item if user is lacking permissions', async () => { const { wrapper } = await createFixtures(f => { f.withOrganizations(); diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx index 29aa272e0c2..69e50c6d0dd 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { NavBar, NavbarContextProvider } from '@/ui/elements/Navbar'; +import { USER_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; import { useUserProfileContext } from '../../contexts'; import { localizationKeys } from '../../localization'; import type { PropsOfComponent } from '../../styledSystem'; @@ -9,14 +10,16 @@ import type { PropsOfComponent } from '../../styledSystem'; export const UserProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, ) => { - const { pages } = useUserProfileContext(); + const { pages, apiKeysProps } = useUserProfileContext(); + + const routes = pages.routes.filter(r => r.id !== USER_PROFILE_NAVBAR_ROUTE_ID.API_KEYS || !apiKeysProps?.hide); return ( {props.children} diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index b137da3d65c..edce7308bed 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -38,7 +38,7 @@ const PaymentAttemptPage = lazy(() => ); export const UserProfileRoutes = () => { - const { pages, shouldShowBilling } = useUserProfileContext(); + const { pages, shouldShowBilling, apiKeysProps } = useUserProfileContext(); const { apiKeysSettings, commerceSettings } = useEnvironment(); const isAccountPageRoot = pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT; @@ -108,7 +108,7 @@ export const UserProfileRoutes = () => { ) : null} - {apiKeysSettings.enabled && ( + {apiKeysSettings.user_api_keys_enabled && !apiKeysProps?.hide && ( diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx index 3cb9884bb50..1b1ea0f6604 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx @@ -241,4 +241,55 @@ describe('UserProfile', () => { expect(fixtures.clerk.billing.getStatements).toHaveBeenCalled(); }); }); + + describe('API Keys visibility', () => { + it('does not include API Keys when hide prop is true', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.apiKeysSettings.user_api_keys_enabled = true; + props.setProps({ apiKeysProps: { hide: true } }); + + render(, { wrapper }); + await waitFor(() => expect(screen.queryByRole('button', { name: /API keys/i })).toBeNull()); + }); + + it('includes API Keys when hide prop is false and user_api_keys_enabled is true', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.apiKeysSettings.user_api_keys_enabled = true; + props.setProps({ apiKeysProps: { hide: false } }); + + render(, { wrapper }); + const apiKeysElements = await screen.findAllByRole('button', { name: /API keys/i }); + expect(apiKeysElements.length).toBeGreaterThan(0); + }); + + it('includes API Keys when hide prop is not set and user_api_keys_enabled is true', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.apiKeysSettings.user_api_keys_enabled = true; + + render(, { wrapper }); + const apiKeysElements = await screen.findAllByRole('button', { name: /API keys/i }); + expect(apiKeysElements.length).toBeGreaterThan(0); + }); + + it('does not include API Keys when user_api_keys_enabled is false even if hide is false', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.environment.apiKeysSettings.user_api_keys_enabled = false; + props.setProps({ apiKeysProps: { hide: false } }); + + render(, { wrapper }); + await waitFor(() => expect(screen.queryByRole('button', { name: /API keys/i })).toBeNull()); + }); + }); }); diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index edb3c58f558..05ced359a16 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -1,9 +1,9 @@ import type { CustomPage, EnvironmentResource, LoadedClerk } from '@clerk/shared/types'; import { - canViewOrManageAPIKeys, - disabledAPIKeysFeature, + disabledOrganizationAPIKeysFeature, disabledOrganizationBillingFeature, + disabledUserAPIKeysFeature, disabledUserBillingFeature, isValidUrl, } from '../../utils'; @@ -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 + ? !disabledOrganizationAPIKeysFeature(clerk, environment) + : !disabledUserAPIKeysFeature(clerk, environment), }); if (isDevelopmentSDK(clerk)) { diff --git a/packages/clerk-js/src/utils/componentGuards.ts b/packages/clerk-js/src/utils/componentGuards.ts index e5f6a6fce74..b8379c6d68c 100644 --- a/packages/clerk-js/src/utils/componentGuards.ts +++ b/packages/clerk-js/src/utils/componentGuards.ts @@ -34,17 +34,14 @@ 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; +}; - return ( - clerk.session.checkAuthorization({ permission: 'org:sys_api_keys:read' }) || - clerk.session.checkAuthorization({ permission: 'org:sys_api_keys:manage' }) - ); +export const disabledAllAPIKeysFeatures: ComponentGuard = (_, environment) => { + return disabledUserAPIKeysFeature(_, environment) && disabledOrganizationAPIKeysFeature(_, environment); }; diff --git a/packages/shared/src/types/apiKeysSettings.ts b/packages/shared/src/types/apiKeysSettings.ts index f5871c1ed2d..2dea9a3412b 100644 --- a/packages/shared/src/types/apiKeysSettings.ts +++ b/packages/shared/src/types/apiKeysSettings.ts @@ -3,11 +3,13 @@ import type { ClerkResource } from './resource'; import type { APIKeysSettingsJSONSnapshot } from './snapshots'; export interface APIKeysSettingsJSON extends ClerkResourceJSON { - enabled: boolean; + user_api_keys_enabled: boolean; + orgs_api_keys_enabled: boolean; } export interface APIKeysSettingsResource extends ClerkResource { - enabled: boolean; + user_api_keys_enabled: boolean; + orgs_api_keys_enabled: boolean; __internal_toSnapshot: () => APIKeysSettingsJSONSnapshot; } diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 859c47c3ae5..7f9259b0103 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1564,7 +1564,14 @@ export type UserProfileProps = RoutingOptions & { * * @experimental */ - apiKeysProps?: APIKeysProps; + apiKeysProps?: APIKeysProps & { + /** + * Whether to hide the API Keys page. When true, the API Keys page will not be displayed even if API keys are enabled. + * + * @default false + */ + hide?: boolean; + }; }; export type UserProfileModalProps = WithoutRouting; @@ -1600,7 +1607,14 @@ export type OrganizationProfileProps = RoutingOptions & { * * @experimental */ - apiKeysProps?: APIKeysProps; + apiKeysProps?: APIKeysProps & { + /** + * Whether to hide the API Keys page. When true, the API Keys page will not be displayed even if API keys are enabled. + * + * @default false + */ + hide?: boolean; + }; }; export type OrganizationProfileModalProps = WithoutRouting;