diff --git a/.changeset/curly-news-push.md b/.changeset/curly-news-push.md new file mode 100644 index 0000000000..fbb6d75891 --- /dev/null +++ b/.changeset/curly-news-push.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Shows list of domains if member has the `org:sys_domain:read` permission. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b719060194..c37cfe97f3 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -15,8 +15,7 @@ import type { import { unixEpochToDate } from '../../utils/date'; import { eventBus, events } from '../events'; import { SessionTokenCache } from '../tokenCache'; -import { PublicUserData } from './internal'; -import { BaseResource, Token, User } from './internal'; +import { BaseResource, PublicUserData, Token, User } from './internal'; export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; @@ -71,7 +70,7 @@ export class Session extends BaseResource implements SessionResource { }; // TODO: Fix this eslint error - // eslint-disable-next-line @typescript-eslint/require-await + getToken: GetToken = async (options?: GetTokenOptions): Promise => { return runWithExponentialBackOff(() => this._getToken(options), { shouldRetry: (error: unknown, currentIteration: number) => !is4xxError(error) && currentIteration < 4, @@ -82,7 +81,7 @@ export class Session extends BaseResource implements SessionResource { * @experimental The method is experimental and subject to change in future releases. */ isAuthorized: IsAuthorized = async params => { - return new Promise(resolve => { + return new Promise((resolve, reject) => { // if there is no active organization user can not be authorized if (!this.lastActiveOrganizationId || !this.user) { return resolve(false); @@ -106,7 +105,22 @@ export class Session extends BaseResource implements SessionResource { if (params.role) { return resolve(activeOrganizationRole === params.role); } - return resolve(false); + + if (params.any) { + return resolve( + !!params.any.find(permObj => { + if (permObj.permission) { + return activeOrganizationPermissions.includes(permObj.permission); + } + if (permObj.role) { + return activeOrganizationRole === permObj.role; + } + return false; + }), + ); + } + + return reject(); }); }; diff --git a/packages/clerk-js/src/core/test/fixtures.ts b/packages/clerk-js/src/core/test/fixtures.ts index efe2a5d20c..a30f1d4ef9 100644 --- a/packages/clerk-js/src/core/test/fixtures.ts +++ b/packages/clerk-js/src/core/test/fixtures.ts @@ -13,7 +13,7 @@ import type { export const mockJwt = 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.n1Usc-DLDftqA0Xb-_2w8IGs4yjCmwc5RngwbSRvwevuZOIuRoeHmE2sgCdEvjfJEa7ewL6EVGVcM557TWPW--g_J1XQPwBy8tXfz7-S73CEuyRFiR97L2AHRdvRtvGtwR-o6l8aHaFxtlmfWbQXfg4kFJz2UGe9afmh3U9-f_4JOZ5fa3mI98UMy1-bo20vjXeWQ9aGrqaxHQxjnzzC-1Kpi5LdPvhQ16H0dPB8MHRTSM5TAuLKTpPV7wqixmbtcc2-0k6b9FKYZNqRVTaIyV-lifZloBvdzlfOF8nW1VVH_fx-iW5Q3hovHFcJIULHEC1kcAYTubbxzpgeVQepGg'; -type OrgParams = Partial & { role?: MembershipRole; permissions?: OrganizationPermission[] }; +export type OrgParams = Partial & { role?: MembershipRole; permissions?: OrganizationPermission[] }; type WithUserParams = Omit< Partial, diff --git a/packages/clerk-js/src/ui/common/Gate.tsx b/packages/clerk-js/src/ui/common/Gate.tsx index ca4af25c1b..7da69f6b22 100644 --- a/packages/clerk-js/src/ui/common/Gate.tsx +++ b/packages/clerk-js/src/ui/common/Gate.tsx @@ -1,4 +1,4 @@ -import type { IsAuthorized, OrganizationPermission } from '@clerk/types'; +import type { IsAuthorized } from '@clerk/types'; import type { ComponentType, PropsWithChildren, ReactNode } from 'react'; import React, { useEffect } from 'react'; @@ -6,7 +6,7 @@ import { useCoreSession } from '../contexts'; import { useFetch } from '../hooks'; import { useRouter } from '../router'; -type GateParams = Omit[0], 'permission'> & { permission: OrganizationPermission }; +type GateParams = Parameters[0]; type GateProps = PropsWithChildren< GateParams & { fallback?: ReactNode; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx index 7f94a02ef7..b782829b78 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx @@ -1,9 +1,15 @@ -import type { GetDomainsParams, OrganizationEnrollmentMode } from '@clerk/types'; -import type { OrganizationDomainVerificationStatus } from '@clerk/types'; +import type { + GetDomainsParams, + OrganizationDomainResource, + OrganizationDomainVerificationStatus, + OrganizationEnrollmentMode, +} from '@clerk/types'; import React, { useMemo } from 'react'; -import { withGate } from '../../common'; +import { stripOrigin, toURL, trimLeadingSlash } from '../../../utils'; +import { useGate, withGate } from '../../common'; import { useCoreOrganization } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; import { Box, Col, localizationKeys, Spinner } from '../../customizables'; import { ArrowBlockButton, BlockWithTrailingComponent, ThreeDotsMenu } from '../../elements'; import { useInView } from '../../hooks'; @@ -17,10 +23,61 @@ type DomainListProps = GetDomainsParams & { * Enables internal links to navigate to the correct page * based on when this component is used */ - redirectSubPath: string; + redirectSubPath: 'organization-settings/domain' | 'domain'; fallback?: React.ReactNode; }; +const useDomainList = () => { + const { isAuthorizedUser: canDeleteDomain } = useGate({ permission: 'org:sys_domains:delete' }); + const { isAuthorizedUser: canVerifyDomain } = useGate({ permission: 'org:sys_domains:manage' }); + + return { + showDotMenu: canDeleteDomain || canVerifyDomain, + canVerifyDomain, + canDeleteDomain, + }; +}; + +const buildDomainListRelativeURL = (parentPath: string, domainId: string, mode?: 'verify' | 'remove') => + trimLeadingSlash(stripOrigin(toURL(`${parentPath}/${domainId}/${mode || ''}`))); + +const useMenuActions = ( + parentPath: string, + domainId: string, +): { label: LocalizationKey; onClick: () => Promise; isDestructive?: boolean }[] => { + const { canDeleteDomain, canVerifyDomain } = useDomainList(); + const { navigate } = useRouter(); + + const menuActions = []; + + if (canVerifyDomain) { + menuActions.push({ + label: localizationKeys('organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__verify'), + onClick: () => navigate(buildDomainListRelativeURL(parentPath, domainId, 'verify')), + }); + } + + if (canDeleteDomain) { + menuActions.push({ + label: localizationKeys('organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove'), + isDestructive: true, + onClick: () => navigate(buildDomainListRelativeURL(parentPath, domainId, 'remove')), + }); + } + + return menuActions; +}; + +const DomainListDotMenu = ({ + redirectSubPath, + domainId, +}: Pick & { + domainId: OrganizationDomainResource['id']; +}) => { + const actions = useMenuActions(redirectSubPath, domainId); + return ; +}; + export const DomainList = withGate( (props: DomainListProps) => { const { verificationStatus, enrollmentMode, redirectSubPath, fallback, ...rest } = props; @@ -31,6 +88,7 @@ export const DomainList = withGate( }, }); + const { showDotMenu } = useDomainList(); const { ref } = useInView({ threshold: 0, onChange: inView => { @@ -69,7 +127,7 @@ export const DomainList = withGate( {domainList.length === 0 && !domains?.isLoading && fallback} {domainList.map(d => { - if (!(d.verification && d.verification.status === 'verified')) { + if (!(d.verification && d.verification.status === 'verified') || !showDotMenu) { return ( } trailingComponent={ - navigate(`${redirectSubPath}${d.id}/verify`), - }, - { - label: localizationKeys( - 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove', - ), - isDestructive: true, - onClick: () => navigate(`${redirectSubPath}${d.id}/remove`), - }, - ]} - /> + showDotMenu ? ( + + ) : undefined } > {d.name} @@ -116,7 +163,7 @@ export const DomainList = withGate( padding: `${t.space.$3} ${t.space.$4}`, minHeight: t.sizes.$10, })} - onClick={() => navigate(`${redirectSubPath}${d.id}`)} + onClick={() => navigate(buildDomainListRelativeURL(redirectSubPath, d.id))} > {d.name} @@ -154,6 +201,6 @@ export const DomainList = withGate( ); }, { - permission: 'org:sys_domains:manage', + permission: 'org:sys_domains:read', }, ); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabInvitations.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabInvitations.tsx index 7a57c04c7c..a533393fe3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabInvitations.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabInvitations.tsx @@ -58,7 +58,7 @@ export const OrganizationMembersTabInvitations = () => { onClick={() => navigate('organization-settings/domain')} /> } - redirectSubPath={'organization-settings/domain/'} + redirectSubPath={'organization-settings/domain'} verificationStatus={'verified'} enrollmentMode={'automatic_invitation'} /> diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx index 5915a014b7..a5078b639d 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx @@ -1,5 +1,5 @@ -import { BlockButton } from '../../common'; -import { useOrganizationProfileContext } from '../../contexts'; +import { BlockButton, Gate } from '../../common'; +import { useEnvironment, useOrganizationProfileContext } from '../../contexts'; import { Col, Flex, localizationKeys } from '../../customizables'; import { Header } from '../../elements'; import { useRouter } from '../../router'; @@ -8,10 +8,13 @@ import { MembershipWidget } from './MembershipWidget'; import { RequestToJoinList } from './RequestToJoinList'; export const OrganizationMembersTabRequests = () => { + const { organizationSettings } = useEnvironment(); const { navigate } = useRouter(); //@ts-expect-error const { __unstable_manageBillingUrl } = useOrganizationProfileContext(); + const isDomainsEnabled = organizationSettings?.domains?.enabled; + return ( { }} > {__unstable_manageBillingUrl && } - - - - - - navigate('organization-settings/domain')} + + {isDomainsEnabled && ( + + + + + + + navigate('organization-settings/domain')} + /> + } + redirectSubPath={'organization-settings/domain'} + verificationStatus={'verified'} + enrollmentMode={'automatic_suggestion'} /> - } - redirectSubPath={'organization-settings/domain/'} - verificationStatus={'verified'} - enrollmentMode={'automatic_suggestion'} - /> - + + + )} diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationSettings.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationSettings.tsx index e0ae36041a..faca9fa90c 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationSettings.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationSettings.tsx @@ -26,7 +26,7 @@ export const OrganizationSettings = () => { - + @@ -85,7 +85,7 @@ const OrganizationDomainsSection = () => { subtitle={localizationKeys('organizationProfile.profilePage.domainSection.subtitle')} id='organizationDomains' > - + { const card = useCardState(); const { organizationSettings } = useEnvironment(); - const { organization } = useCoreOrganization(); - const { domains } = useCoreOrganization({ + + const { organization, domains } = useCoreOrganization({ domains: { infinite: true, }, }); + const { isAuthorizedUser: canManageDomain } = useGate({ permission: 'org:sys_domains:manage' }); + const { isAuthorizedUser: canDeleteDomain } = useGate({ permission: 'org:sys_domains:delete' }); + const { navigateToFlowStart } = useNavigateToFlowStart(); const { params, navigate, queryParams } = useRouter(); const mode = (queryParams.mode || 'edit') as 'select' | 'edit'; @@ -200,69 +203,74 @@ export const VerifiedDomainPage = withCardStateProvider(() => { - - {allowsEdit && ( + {canManageDomain && ( + + )} + {allowsEdit && canDeleteDomain && ( )} - - {calloutLabel.length > 0 && ( - - {calloutLabel.map((label, index) => ( - ({ - lineHeight: t.lineHeights.$short, - color: 'inherit', - display: 'block', - }), - ]} - localizationKey={label} - /> - ))} - - )} - - - - - - - - - {allowsEdit && ( - - - + {calloutLabel.length > 0 && ( + + {calloutLabel.map((label, index) => ( + ({ + lineHeight: t.lineHeights.$short, + color: 'inherit', + display: 'block', + }), + ]} + localizationKey={label} + /> + ))} + )} + + + + + + + - + + )} - isDisabled={domainStatus.isLoading || !domain || !isFormDirty} - /> - - - {allowsEdit && ( + + + + )} + {allowsEdit && canDeleteDomain && ( { it('renders the Organization Members page', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - const { getByText, getByRole } = render(, { wrapper }); await waitFor(() => { @@ -34,14 +32,12 @@ describe('OrganizationMembers', () => { }); it('shows requests if domains is turned on', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withOrganizationDomains(); f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1'] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - const { getByRole } = render(, { wrapper }); await waitFor(() => { @@ -50,13 +46,11 @@ describe('OrganizationMembers', () => { }); it('shows an invite button inside invitations tab if the current user is an admin', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - const { getByRole, getByText } = render(, { wrapper }); await waitFor(async () => { @@ -67,16 +61,14 @@ describe('OrganizationMembers', () => { }); it('does not show invitations and requests if user is not an admin', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], - organization_memberships: [{ name: 'Org1' }], + organization_memberships: [{ name: 'Org1', permissions: [] }], }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); - const { queryByRole } = render(, { wrapper }); await waitFor(() => { @@ -92,8 +84,6 @@ describe('OrganizationMembers', () => { f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: 'admin' }] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - const { getByRole } = render(, { wrapper }); await waitFor(async () => { @@ -155,8 +145,6 @@ describe('OrganizationMembers', () => { }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - const { queryByText, queryAllByRole } = render(, { wrapper }); await waitFor(() => { @@ -203,8 +191,6 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - fixtures.clerk.organization?.getMemberships.mockReturnValueOnce( Promise.resolve({ data: membersList, total_count: 0 }), ); @@ -240,8 +226,6 @@ describe('OrganizationMembers', () => { }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - await runFakeTimers(async () => { const { getByText } = render(, { wrapper }); await waitFor(() => { @@ -277,8 +261,6 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - fixtures.clerk.organization?.getInvitations.mockReturnValue( Promise.resolve({ data: invitationList, @@ -327,8 +309,6 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - fixtures.clerk.organization?.getDomains.mockReturnValue( Promise.resolve({ data: [], @@ -373,7 +353,6 @@ describe('OrganizationMembers', () => { }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); const { findByText } = render(, { wrapper }); await waitFor(() => expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled()); expect(await findByText('You')).toBeInTheDocument(); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx index 79faeb4964..f17a1c2195 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx @@ -1,8 +1,8 @@ import type { OrganizationDomainResource, OrganizationMembershipResource } from '@clerk/types'; import { describe, it } from '@jest/globals'; -import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { act, render, waitFor } from '../../../../testUtils'; import { bindCreateFixtures } from '../../../utils/test/createFixtures'; import { OrganizationSettings } from '../OrganizationSettings'; import { createFakeDomain, createFakeMember } from './utils'; @@ -28,7 +28,7 @@ describe('OrganizationSettings', () => { total_count: 1, }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); + const { getByText } = render(, { wrapper }); await waitFor(() => { expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled(); @@ -54,7 +54,6 @@ describe('OrganizationSettings', () => { total_count: 1, }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); const { getByText } = render(, { wrapper }); await waitFor(() => { expect(getByText('Settings')).toBeDefined(); @@ -75,7 +74,6 @@ describe('OrganizationSettings', () => { }); fixtures.clerk.organization?.getMemberships.mockReturnValue(Promise.resolve(adminsList)); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); const { getByText } = render(, { wrapper }); await waitFor(() => { expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled(); @@ -85,9 +83,46 @@ describe('OrganizationSettings', () => { }); }); + it('hides domains when `read` permission is missing', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(); + f.withUser({ + email_addresses: ['test@clerk.dev'], + organization_memberships: [{ name: 'Org1', permissions: ['org:sys_memberships:read'] }], + }); + }); + const { queryByText } = await act(() => render(, { wrapper })); + await new Promise(r => setTimeout(r, 100)); + expect(queryByText('Verified domains')).not.toBeInTheDocument(); + expect(fixtures.clerk.organization?.getDomains).not.toBeCalled(); + }); + + it('shows domains when `read` permission exists', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(); + f.withUser({ + email_addresses: ['test@clerk.dev'], + organization_memberships: [{ name: 'Org1', permissions: ['org:sys_domains:read'] }], + }); + }); + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + const { queryByText } = await act(() => render(, { wrapper })); + + await new Promise(r => setTimeout(r, 100)); + expect(queryByText('Verified domains')).toBeInTheDocument(); + expect(fixtures.clerk.organization?.getDomains).toBeCalled(); + }); + describe('Danger section', () => { it('always displays danger section and the leave organization button', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], @@ -95,8 +130,7 @@ describe('OrganizationSettings', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); - const { getByText, queryByRole } = render(, { wrapper }); + const { getByText, queryByRole } = await act(() => render(, { wrapper })); await waitFor(() => { expect(getByText('Danger')).toBeDefined(); expect(getByText(/leave organization/i).closest('button')).toBeInTheDocument(); @@ -105,7 +139,7 @@ describe('OrganizationSettings', () => { }); it('enabled leave organization button with delete organization button', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], @@ -113,7 +147,6 @@ describe('OrganizationSettings', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); const { getByText } = render(, { wrapper }); await waitFor(() => { expect(getByText('Danger')).toBeDefined(); @@ -144,7 +177,6 @@ describe('OrganizationSettings', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); fixtures.clerk.organization?.getMemberships.mockReturnValue(Promise.resolve(adminsList)); const { getByText, getByRole } = render(, { wrapper }); await waitFor(() => { @@ -173,7 +205,6 @@ describe('OrganizationSettings', () => { total_count: 0, }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); const { getByText } = render(, { wrapper }); await waitFor(async () => { await userEvent.click(getByText('Org1', { exact: false })); @@ -193,7 +224,6 @@ describe('OrganizationSettings', () => { }); fixtures.clerk.organization?.getMemberships.mockReturnValue(Promise.resolve(adminsList)); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); const { findByText } = render(, { wrapper }); await waitFor(async () => { // expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled(); diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index 844ba72a33..711ee40fcd 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -10,33 +10,30 @@ const { createFixtures } = bindCreateFixtures('OrganizationSwitcher'); describe('OrganizationSwitcher', () => { it('renders component', async () => { - const { wrapper, fixtures } = await createFixtures(f => { + const { wrapper } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); const { queryByRole } = await act(() => render(, { wrapper })); expect(queryByRole('button')).toBeDefined(); }); describe('Personal Workspace', () => { it('shows the personal workspace when enabled', async () => { - const { wrapper, props, fixtures } = await createFixtures(f => { + const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); props.setProps({ hidePersonal: false }); const { getByText } = await act(() => render(, { wrapper })); expect(getByText('Personal account')).toBeDefined(); }); it('does not show the personal workspace when disabled', async () => { - const { wrapper, props, fixtures } = await createFixtures(f => { + const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); props.setProps({ hidePersonal: true }); const { queryByText, getByRole, userEvent, getByText } = render(, { wrapper }); await userEvent.click(getByRole('button')); @@ -49,7 +46,10 @@ describe('OrganizationSwitcher', () => { it('shows the counter for pending suggestions and invitations', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withOrganizations(); - f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', id: '1', permissions: ['org:sys_memberships:manage'] }], + }); }); fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( @@ -66,8 +66,6 @@ describe('OrganizationSwitcher', () => { }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - await runFakeTimers(async () => { const { getByText } = render(, { wrapper }); @@ -108,8 +106,6 @@ describe('OrganizationSwitcher', () => { }), ); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); - await runFakeTimers(async () => { const { getByText } = render(, { wrapper }); @@ -122,11 +118,11 @@ describe('OrganizationSwitcher', () => { describe('OrganizationSwitcherPopover', () => { it('opens the organization switcher popover when clicked', async () => { - const { wrapper, props, fixtures } = await createFixtures(f => { + const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], create_organization_enabled: true }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + props.setProps({ hidePersonal: true }); const { getByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button')); @@ -134,11 +130,11 @@ describe('OrganizationSwitcher', () => { }); it('lists all organizations the user belongs to', async () => { - const { wrapper, props, fixtures } = await createFixtures(f => { + const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: ['Org1', 'Org2'] }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + props.setProps({ hidePersonal: false }); const { getAllByText, getByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button')); @@ -152,14 +148,14 @@ describe('OrganizationSwitcher', () => { ['Member', 'basic_member'], ['Guest', 'guest_member'], ])('shows the text "%s" for the %s role in the active organization', async (text, role) => { - const { wrapper, props, fixtures } = await createFixtures(f => { + const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'Org1', role: role as MembershipRole }], }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + props.setProps({ hidePersonal: true }); const { getAllByText, getByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button')); @@ -175,7 +171,7 @@ describe('OrganizationSwitcher', () => { organization_memberships: [{ name: 'Org1', role: 'basic_member' }], }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + props.setProps({ hidePersonal: true }); const { getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button')); @@ -192,7 +188,7 @@ describe('OrganizationSwitcher', () => { create_organization_enabled: true, }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + props.setProps({ hidePersonal: true }); const { getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button', { name: 'Open organization switcher' })); @@ -201,7 +197,7 @@ describe('OrganizationSwitcher', () => { }); it('does not display create organization button if permissions not present', async () => { - const { wrapper, props, fixtures } = await createFixtures(f => { + const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); f.withUser({ email_addresses: ['test@clerk.com'], @@ -209,7 +205,7 @@ describe('OrganizationSwitcher', () => { create_organization_enabled: false, }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + props.setProps({ hidePersonal: true }); const { queryByRole } = await act(() => render(, { wrapper })); expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument(); @@ -224,7 +220,7 @@ describe('OrganizationSwitcher', () => { create_organization_enabled: false, }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( Promise.resolve({ data: [ @@ -268,7 +264,7 @@ describe('OrganizationSwitcher', () => { create_organization_enabled: false, }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValueOnce( Promise.resolve({ data: [ @@ -318,7 +314,6 @@ describe('OrganizationSwitcher', () => { }); }); fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); props.setProps({ hidePersonal: true }); const { getByRole, getByText, userEvent } = render(, { wrapper }); @@ -346,7 +341,6 @@ describe('OrganizationSwitcher', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(false); fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); const { getByRole, getByText, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button')); diff --git a/packages/clerk-js/src/ui/hooks/useFetch.ts b/packages/clerk-js/src/ui/hooks/useFetch.ts index f725b520bb..87c81d4347 100644 --- a/packages/clerk-js/src/ui/hooks/useFetch.ts +++ b/packages/clerk-js/src/ui/hooks/useFetch.ts @@ -35,7 +35,7 @@ export const useFetch = ( requestStatus.setError(); setData(null); }); - }, []); + }, [JSON.stringify(params)]); return { status: requestStatus, diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index 23722baee5..4fdaadc6b0 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -6,8 +6,8 @@ import type { ExternalAccountJSON, OAuthProvider, OrganizationEnrollmentMode, - OrganizationJSON, PhoneNumberJSON, + PublicUserDataJSON, SamlAccountJSON, SessionJSON, SignInJSON, @@ -15,8 +15,8 @@ import type { UserJSON, UserSettingsJSON, } from '@clerk/types'; -import type { MembershipRole, PublicUserDataJSON } from '@clerk/types'; +import type { OrgParams } from '../../../core/test/fixtures'; import { createUser, getOrganizationId } from '../../../core/test/fixtures'; import { createUserFixture } from './fixtures'; @@ -38,8 +38,6 @@ export const createClientFixtureHelpers = (baseClient: ClientJSON) => { }; const createUserFixtureHelpers = (baseClient: ClientJSON) => { - type OrgParams = Partial & { role?: MembershipRole }; - type WithUserParams = Omit< Partial, 'email_addresses' | 'phone_numbers' | 'external_accounts' | 'saml_accounts' | 'organization_memberships' diff --git a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts index 24424e9cc0..e8dc6477cd 100644 --- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts @@ -20,8 +20,13 @@ const mockProp = (obj: T, k: keyof T) => { } }; -const mockMethodsOf = (obj: any) => { - Object.keys(obj).forEach(k => mockProp(obj, k)); +const mockMethodsOf = | null = any>(obj: T, options?: { exclude: (keyof T)[] }) => { + if (!obj) { + return; + } + Object.keys(obj) + .filter(key => !options?.exclude.includes(key as keyof T)) + .forEach(k => mockProp(obj, k)); }; export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked => { @@ -29,11 +34,13 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked { - mockMethodsOf(session); + mockMethodsOf(session, { + exclude: ['isAuthorized'], + }); mockMethodsOf(session.user); - session.user?.emailAddresses.forEach(mockMethodsOf); - session.user?.phoneNumbers.forEach(mockMethodsOf); - session.user?.externalAccounts.forEach(mockMethodsOf); + session.user?.emailAddresses.forEach(m => mockMethodsOf(m)); + session.user?.phoneNumbers.forEach(m => mockMethodsOf(m)); + session.user?.externalAccounts.forEach(m => mockMethodsOf(m)); session.user?.organizationMemberships.forEach(m => { mockMethodsOf(m); mockMethodsOf(m.organization); diff --git a/packages/clerk-js/src/utils/__tests__/url.test.ts b/packages/clerk-js/src/utils/__tests__/url.test.ts index 8f5e715c75..29fec96f5f 100644 --- a/packages/clerk-js/src/utils/__tests__/url.test.ts +++ b/packages/clerk-js/src/utils/__tests__/url.test.ts @@ -15,6 +15,7 @@ import { isValidUrl, mergeFragmentIntoUrl, requiresUserInput, + trimLeadingSlash, trimTrailingSlash, } from '../url'; @@ -239,6 +240,15 @@ describe('trimTrailingSlash(string)', () => { }); }); +describe('trimLeadingSlash(string)', () => { + it('trims all the leading slashes', () => { + expect(trimLeadingSlash('')).toBe(''); + expect(trimLeadingSlash('/foo')).toBe('foo'); + expect(trimLeadingSlash('/foo/')).toBe('foo/'); + expect(trimLeadingSlash('//foo//bar///')).toBe('foo//bar///'); + }); +}); + describe('appendQueryParams(base,url)', () => { it('returns the same url if no params provided', () => { const base = new URL('https://dashboard.clerk.com'); diff --git a/packages/clerk-js/src/utils/url.ts b/packages/clerk-js/src/utils/url.ts index 685d9f1a0b..fd3a991c5d 100644 --- a/packages/clerk-js/src/utils/url.ts +++ b/packages/clerk-js/src/utils/url.ts @@ -211,6 +211,18 @@ export const trimTrailingSlash = (path: string): string => { return (path || '').replace(/\/+$/, ''); }; +/** + * trimLeadingSlash(path: string): string + * + * Strips the leading slashes from a string + * + * @returns {string} Returns the string without leading slashes + * @param path + */ +export const trimLeadingSlash = (path: string): string => { + return (path || '').replace(/^\/+/, ''); +}; + export const stripSameOrigin = (url: URL, baseUrl: URL): string => { const sameOrigin = baseUrl.origin === url.origin; return sameOrigin ? stripOrigin(url) : `${url}`; diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 006dfc25dc..3eaecba62e 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -6,12 +6,35 @@ import type { UserResource } from './user'; export type IsAuthorized = (isAuthorizedParams: IsAuthorizedParams) => Promise; -interface IsAuthorizedParams { - // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string - // eslint-disable-next-line - permission?: OrganizationPermission | (string & {}); - role?: string; -} +type IsAuthorizedParams = + | { + any: ( + | { + role: string; + permission?: never; + } + | { + role?: never; + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + permission: OrganizationPermission | (string & {}); + } + )[]; + role?: never; + permission?: never; + } + | { + any?: never; + role: string; + permission?: never; + } + | { + any?: never; + role?: never; + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + permission: OrganizationPermission | (string & {}); + }; type IsAuthorizedReturnValues = boolean;