diff --git a/.changeset/cuddly-kiwis-rush.md b/.changeset/cuddly-kiwis-rush.md new file mode 100644 index 00000000000..0799f5a322b --- /dev/null +++ b/.changeset/cuddly-kiwis-rush.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": minor +--- + +Added granular permission checks to `` component to support read-only and manage roles diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index e2d2c439dd2..f0f17f982a5 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -1,22 +1,25 @@ import { expect, test } from '@playwright/test'; import { appConfigs } from '../../presets'; -import type { FakeUser } from '../../testUtils'; +import type { FakeOrganization, FakeUser } from '../../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @generic', ({ app }) => { test.describe.configure({ mode: 'serial' }); - let fakeUser: FakeUser; + let fakeAdmin: FakeUser; + let fakeOrganization: FakeOrganization; test.beforeAll(async () => { const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); + fakeAdmin = u.services.users.createFakeUser(); + const admin = await u.services.users.createBapiUser(fakeAdmin); + fakeOrganization = await u.services.users.createFakeOrganization(admin.id); }); test.afterAll(async () => { - await fakeUser.deleteIfExists(); + await fakeOrganization.delete(); + await fakeAdmin.deleteIfExists(); await app.teardown(); }); @@ -24,7 +27,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); @@ -33,7 +36,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge // Create API key 1 await u.po.apiKeys.clickAddButton(); await u.po.apiKeys.waitForFormOpened(); - await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-1`); + await u.po.apiKeys.typeName(`${fakeAdmin.firstName}-api-key-1`); await u.po.apiKeys.selectExpiration('1d'); await u.po.apiKeys.clickSaveButton(); @@ -42,7 +45,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge // Create API key 2 await u.po.apiKeys.clickAddButton(); await u.po.apiKeys.waitForFormOpened(); - await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-2`); + await u.po.apiKeys.typeName(`${fakeAdmin.firstName}-api-key-2`); await u.po.apiKeys.selectExpiration('7d'); await u.po.apiKeys.clickSaveButton(); @@ -54,13 +57,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); - const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`; // Create API key await u.po.apiKeys.clickAddButton(); @@ -95,13 +98,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); - const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`; // Create API key await u.po.apiKeys.clickAddButton(); @@ -133,13 +136,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); - const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`; // Create API key await u.po.apiKeys.clickAddButton(); @@ -169,4 +172,82 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge await row.locator('.cl-apiKeysRevealButton').click(); await expect(row.locator('input')).toHaveAttribute('type', 'password'); }); + + test('component does not render for orgs when user does not have permissions', 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.services.clerk.organizations.createOrganizationMembership({ + organizationId: fakeOrganization.organization.id, + role: 'org:member', + userId: member.id, + }); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeMember.email, password: fakeMember.password }); + await u.po.expect.toBeSignedIn(); + + 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(); + }); + + test('user with read permission can view API keys but not manage them', 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.expect.toBeSignedIn(); + + 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 fakeViewer.deleteIfExists(); + }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 09c556354dd..3edd92caf01 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "111.8KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "111.9KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5374806db9c..763f1f78ec5 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -86,6 +86,7 @@ import type { MountComponentRenderer } from '../ui/Components'; import { ALLOWED_PROTOCOLS, buildURL, + canViewOrManageAPIKeys, completeSignUpFlow, createAllowedRedirectOrigins, createBeforeUnloadTracker, @@ -168,6 +169,7 @@ 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 defaultOptions: ClerkOptions = { polling: true, standardBrowser: true, @@ -1099,6 +1101,16 @@ export class Clerk implements ClerkInterface { } return; } + + if (this.organization && !canViewOrManageAPIKeys(this)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenUnauthorized, { + code: CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE, + }); + } + return; + } + void this.#componentControls.ensureMounted({ preloadHint: 'APIKeys' }).then(controls => controls.mountComponent({ name: 'APIKeys', diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 2af87837fec..7afd2e4c493 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -40,6 +40,8 @@ const warnings = { '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.', }; type SerializableWarnings = Serializable; diff --git a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx index 95d2aaf2c05..37b25301659 100644 --- a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx +++ b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx @@ -4,6 +4,7 @@ import type { CreateAPIKeyParams } from '@clerk/types'; import { lazy, useState } from 'react'; import useSWRMutation from 'swr/mutation'; +import { useProtect } from '@/ui/common'; import { useApiKeysContext, withCoreUserGuard } from '@/ui/contexts'; import { Box, @@ -22,6 +23,7 @@ import { InputWithIcon } from '@/ui/elements/InputWithIcon'; import { Pagination } from '@/ui/elements/Pagination'; import { MagnifyingGlass } from '@/ui/icons'; import { mqu } from '@/ui/styledSystem'; +import { isOrganizationId } from '@/utils'; import { ApiKeysTable } from './ApiKeysTable'; import type { OnCreateParams } from './CreateApiKeyForm'; @@ -41,6 +43,10 @@ const RevokeAPIKeyConfirmationModal = lazy(() => ); export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => { + const isOrg = isOrganizationId(subject); + const canReadAPIKeys = useProtect({ permission: 'org:sys_api_keys:read' }); + const canManageAPIKeys = useProtect({ permission: 'org:sys_api_keys:manage' }); + const { apiKeys, isLoading, @@ -53,7 +59,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr startingRow, endingRow, cacheKey, - } = useApiKeys({ subject, perPage }); + } = useApiKeys({ subject, perPage, enabled: isOrg ? canReadAPIKeys : true }); const card = useCardState(); const { trigger: createApiKey, isMutating } = useSWRMutation(cacheKey, (_, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg), @@ -118,16 +124,18 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr elementDescriptor={descriptors.apiKeysSearchInput} /> - -