From 74a2cbe33a3fb6e53691a8a254572aa6f790d142 Mon Sep 17 00:00:00 2001 From: George Desipris <73396808+desiprisg@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:14:42 +0300 Subject: [PATCH 1/9] fix(clerk-js): Correct initial ref value for form feedback (#1933) --- .changeset/odd-lemons-reply.md | 2 ++ packages/clerk-js/src/ui/elements/FormControl.tsx | 8 +++----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 .changeset/odd-lemons-reply.md diff --git a/.changeset/odd-lemons-reply.md b/.changeset/odd-lemons-reply.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/odd-lemons-reply.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/src/ui/elements/FormControl.tsx b/packages/clerk-js/src/ui/elements/FormControl.tsx index 0838dd6e3a3..2c4546a4a90 100644 --- a/packages/clerk-js/src/ui/elements/FormControl.tsx +++ b/packages/clerk-js/src/ui/elements/FormControl.tsx @@ -1,8 +1,6 @@ -import type { FieldId } from '@clerk/types'; -import type { ClerkAPIError } from '@clerk/types'; +import type { ClerkAPIError, FieldId } from '@clerk/types'; import type { PropsWithChildren } from 'react'; -import { useRef } from 'react'; -import React, { forwardRef, useCallback, useMemo, useState } from 'react'; +import React, { forwardRef, useCallback, useMemo, useRef, useState } from 'react'; import type { LocalizationKey } from '../customizables'; import { @@ -151,7 +149,7 @@ export const FormFeedback = (props: FormFeedbackProps) => { const feedbacksRef = useRef<{ a?: Feedback; b?: Feedback; - }>({ a: { feedback, feedbackType, shouldEnter: true }, b: undefined }); + }>({ a: undefined, b: undefined }); const { getFormTextAnimation } = useFormTextAnimation(); const defaultElementDescriptors = { From 28ec80d29795ea15a48dbb3a9d1d49ea63061ddc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 16 Oct 2023 19:49:16 +0300 Subject: [PATCH 2/9] chore(clerk-js): Use `org:sys_domains:read` --- .../clerk-js/src/core/resources/Session.ts | 5 +- packages/clerk-js/src/ui/common/Gate.tsx | 2 +- .../OrganizationProfile/DomainList.tsx | 80 ++++++++---- .../OrganizationMembersTabRequests.tsx | 82 ++++++------ .../OrganizationProfileRoutes.tsx | 2 +- .../OrganizationSettings.tsx | 2 +- .../VerifiedDomainPage.tsx | 118 ++++++++++-------- packages/clerk-js/src/ui/hooks/useFetch.ts | 2 +- packages/types/src/session.ts | 3 + 9 files changed, 178 insertions(+), 118 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b719060194a..3afc7b68c75 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -106,7 +106,10 @@ 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.filter(perm => activeOrganizationPermissions.includes(perm)).length > 0); + } }); }; diff --git a/packages/clerk-js/src/ui/common/Gate.tsx b/packages/clerk-js/src/ui/common/Gate.tsx index ca4af25c1bb..c5797be22b6 100644 --- a/packages/clerk-js/src/ui/common/Gate.tsx +++ b/packages/clerk-js/src/ui/common/Gate.tsx @@ -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 = Omit[0], 'permission'> & { permission?: OrganizationPermission }; 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 7f94a02ef78..3ba9b07348e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx @@ -1,8 +1,8 @@ -import type { GetDomainsParams, OrganizationEnrollmentMode } from '@clerk/types'; +import type { GetDomainsParams, OrganizationDomainResource, OrganizationEnrollmentMode } from '@clerk/types'; import type { OrganizationDomainVerificationStatus } from '@clerk/types'; import React, { useMemo } from 'react'; -import { withGate } from '../../common'; +import { useGate, withGate } from '../../common'; import { useCoreOrganization } from '../../contexts'; import { Box, Col, localizationKeys, Spinner } from '../../customizables'; import { ArrowBlockButton, BlockWithTrailingComponent, ThreeDotsMenu } from '../../elements'; @@ -21,6 +21,54 @@ type DomainListProps = GetDomainsParams & { 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, + }; +}; + +const DomainListDotMenu = ({ + redirectSubPath, + domainId, +}: Pick & { + domainId: OrganizationDomainResource['id']; +}) => { + const { navigate } = useRouter(); + const { isAuthorizedUser: canDeleteDomain } = useGate({ permission: 'org:sys_domains:delete' }); + const { isAuthorizedUser: canVerifyDomain } = useGate({ permission: 'org:sys_domains:manage' }); + + return ( + navigate(`${redirectSubPath}${domainId}/verify`), + }, + ] + : []), + ...(canDeleteDomain + ? [ + { + label: localizationKeys( + 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove', + ), + isDestructive: true, + onClick: () => navigate(`${redirectSubPath}${domainId}/remove`), + }, + ] + : []), + ]} + /> + ); +}; + export const DomainList = withGate( (props: DomainListProps) => { const { verificationStatus, enrollmentMode, redirectSubPath, fallback, ...rest } = props; @@ -31,6 +79,7 @@ export const DomainList = withGate( }, }); + const { showDotMenu } = useDomainList(); const { ref } = useInView({ threshold: 0, onChange: inView => { @@ -69,7 +118,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} @@ -154,6 +192,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/OrganizationMembersTabRequests.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx index 5915a014b73..b46caafd9b7 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 e0ae36041a9..1681b673ec9 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 = () => { - + diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx index 51143f42042..b9be72731c5 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx @@ -1,6 +1,6 @@ import type { OrganizationDomainResource, OrganizationEnrollmentMode } from '@clerk/types'; -import { CalloutWithAction } from '../../common'; +import { CalloutWithAction, useGate } from '../../common'; import { useCoreOrganization, useEnvironment } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { Col, Flex, localizationKeys, Spinner, Text } from '../../customizables'; @@ -54,13 +54,16 @@ const useCalloutLabel = ( export const VerifiedDomainPage = withCardStateProvider(() => { 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 && ( ( requestStatus.setError(); setData(null); }); - }, []); + }, [JSON.stringify(params)]); return { status: requestStatus, diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 006dfc25dca..ec66119e2bd 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -11,6 +11,9 @@ interface IsAuthorizedParams { // eslint-disable-next-line permission?: OrganizationPermission | (string & {}); role?: string; + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + any?: (OrganizationPermission | (string & {}))[]; } type IsAuthorizedReturnValues = boolean; From c5622fb60bb7a3c172cbfaa44575d156ccc67d15 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 17 Oct 2023 00:21:49 +0300 Subject: [PATCH 3/9] test(clerk-js): Use `org:sys_domains:read` --- packages/clerk-js/src/core/test/fixtures.ts | 2 +- .../__tests__/OrganizationSettings.test.tsx | 39 ++++++++++++++++++- .../src/ui/utils/test/fixtureHelpers.ts | 6 +-- .../clerk-js/src/ui/utils/test/mockHelpers.ts | 31 +++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/test/fixtures.ts b/packages/clerk-js/src/core/test/fixtures.ts index 65d174e6ad0..bbb39afeff6 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/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationSettings.test.tsx index 0613120f841..66a1c0b13c0 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 { render, waitFor } from '../../../../testUtils'; import { bindCreateFixtures } from '../../../utils/test/createFixtures'; import { OrganizationSettings } from '../OrganizationSettings'; import { createFakeDomain, createFakeMember } from './utils'; @@ -85,6 +85,43 @@ 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 } = 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 } = 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 => { diff --git a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts index b6f24754680..c57cba8965b 100644 --- a/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts @@ -6,7 +6,6 @@ import type { ExternalAccountJSON, OAuthProvider, OrganizationEnrollmentMode, - OrganizationJSON, PhoneNumberJSON, SamlAccountJSON, SessionJSON, @@ -15,8 +14,9 @@ import type { UserJSON, UserSettingsJSON, } from '@clerk/types'; -import type { MembershipRole, PublicUserDataJSON } from '@clerk/types'; +import type { 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 24424e9cc06..3be775db55b 100644 --- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts @@ -30,6 +30,37 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked { mockMethodsOf(session); + session.isAuthorized = jest.fn(args => { + return new Promise(resolve => { + // if there is no active organization user can not be authorized + if (!session.lastActiveOrganizationId || !session.user) { + return resolve(false); + } + + // loop through organizationMemberships from client piggybacking + const orgMemberships = session.user.organizationMemberships || []; + const activeMembership = orgMemberships.find(mem => mem.organization.id === session.lastActiveOrganizationId); + + // Based on FAPI this should never happen, but we handle it anyway + if (!activeMembership) { + return resolve(false); + } + + const activeOrganizationPermissions = activeMembership.permissions; + const activeOrganizationRole = activeMembership.role; + + if (args.permission) { + return resolve(activeOrganizationPermissions.includes(args.permission)); + } + if (args.role) { + return resolve(activeOrganizationRole === args.role); + } + + if (args.any) { + return resolve(args.any.filter(perm => activeOrganizationPermissions.includes(perm)).length > 0); + } + }); + }); mockMethodsOf(session.user); session.user?.emailAddresses.forEach(mockMethodsOf); session.user?.phoneNumbers.forEach(mockMethodsOf); From 8fb7cd2dc49eacabc6e8b2637cba054780e8f3f4 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 17 Oct 2023 10:16:42 +0300 Subject: [PATCH 4/9] chore(clerk-js): Add changeset --- .changeset/curly-news-push.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/curly-news-push.md diff --git a/.changeset/curly-news-push.md b/.changeset/curly-news-push.md new file mode 100644 index 00000000000..fbb6d75891e --- /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. From b4eb7517d62d420c864a611af627135ce8d79ec8 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 17 Oct 2023 10:30:32 +0300 Subject: [PATCH 5/9] chore(clerk-js): UseDomainList --- .../src/ui/components/OrganizationProfile/DomainList.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx index 3ba9b07348e..8430061f1ab 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx @@ -27,6 +27,8 @@ const useDomainList = () => { return { showDotMenu: canDeleteDomain || canVerifyDomain, + canVerifyDomain, + canDeleteDomain, }; }; @@ -37,8 +39,7 @@ const DomainListDotMenu = ({ domainId: OrganizationDomainResource['id']; }) => { const { navigate } = useRouter(); - const { isAuthorizedUser: canDeleteDomain } = useGate({ permission: 'org:sys_domains:delete' }); - const { isAuthorizedUser: canVerifyDomain } = useGate({ permission: 'org:sys_domains:manage' }); + const { canDeleteDomain, canVerifyDomain } = useDomainList(); return ( Date: Tue, 17 Oct 2023 11:06:15 +0300 Subject: [PATCH 6/9] feat(clerk-js): Session.isAuthorized now support the `any` parameter --- .../clerk-js/src/core/resources/Session.ts | 13 ++++++- packages/clerk-js/src/ui/common/Gate.tsx | 4 +- .../OrganizationProfileRoutes.tsx | 2 +- .../clerk-js/src/ui/utils/test/mockHelpers.ts | 13 ++++++- packages/types/src/session.ts | 38 ++++++++++++++----- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 3afc7b68c75..238fc734cdc 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -82,7 +82,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); @@ -108,8 +108,17 @@ export class Session extends BaseResource implements SessionResource { } if (params.any) { - return resolve(params.any.filter(perm => activeOrganizationPermissions.includes(perm)).length > 0); + return resolve( + params.any.filter(permObj => { + if (permObj.permission) { + return activeOrganizationPermissions.includes(permObj.permission); + } + return activeOrganizationRole === permObj.role; + }).length > 0, + ); } + + return reject(); }); }; diff --git a/packages/clerk-js/src/ui/common/Gate.tsx b/packages/clerk-js/src/ui/common/Gate.tsx index c5797be22b6..7da69f6b22f 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/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index c928127fa9e..53fd277bfb6 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -77,7 +77,7 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent diff --git a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts index 3be775db55b..6744b98b6d7 100644 --- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts @@ -31,7 +31,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked { mockMethodsOf(session); session.isAuthorized = jest.fn(args => { - return new Promise(resolve => { + return new Promise((resolve, reject) => { // if there is no active organization user can not be authorized if (!session.lastActiveOrganizationId || !session.user) { return resolve(false); @@ -57,8 +57,17 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked activeOrganizationPermissions.includes(perm)).length > 0); + return resolve( + args.any.filter(permObj => { + if (permObj.permission) { + return activeOrganizationPermissions.includes(permObj.permission); + } + return activeOrganizationRole === permObj.role; + }).length > 0, + ); } + + return reject(false); }); }); mockMethodsOf(session.user); diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index ec66119e2bd..3eaecba62ec 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -6,15 +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; - // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string - // eslint-disable-next-line - any?: (OrganizationPermission | (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; From d8acfa84e0718dbf83d06c8bc35e2e72a127f25b Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 18 Oct 2023 11:23:39 +0300 Subject: [PATCH 7/9] chore(clerk-js): Improve relative url creation in DomainList --- .../OrganizationProfile/DomainList.tsx | 69 ++++++++++--------- .../OrganizationMembersTabInvitations.tsx | 2 +- .../OrganizationMembersTabRequests.tsx | 2 +- .../OrganizationSettings.tsx | 2 +- packages/clerk-js/src/utils/url.ts | 12 ++++ 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx index 8430061f1ab..db0639f0001 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx @@ -2,6 +2,7 @@ import type { GetDomainsParams, OrganizationDomainResource, OrganizationEnrollme import type { OrganizationDomainVerificationStatus } from '@clerk/types'; import React, { useMemo } from 'react'; +import { stripOrigin, toURL, trimLeadingSlash } from '../../../utils'; import { useGate, withGate } from '../../common'; import { useCoreOrganization } from '../../contexts'; import { Box, Col, localizationKeys, Spinner } from '../../customizables'; @@ -17,7 +18,7 @@ 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; }; @@ -32,42 +33,46 @@ const useDomainList = () => { }; }; +const buildDomainListRelativeURL = (parentPath: string, domainId: string, mode?: 'verify' | 'remove') => + trimLeadingSlash(stripOrigin(toURL(`${parentPath}/${domainId}/${mode || ''}`))); + +const useMenuActions = (parentPath: string, domainId: string) => { + const { canDeleteDomain, canVerifyDomain } = useDomainList(); + const { navigate } = useRouter(); + + return [ + ...(canVerifyDomain + ? [ + { + label: localizationKeys( + 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__verify', + ), + onClick: () => navigate(buildDomainListRelativeURL(parentPath, domainId, 'verify')), + }, + ] + : []), + ...(canDeleteDomain + ? [ + { + label: localizationKeys( + 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove', + ), + isDestructive: true, + onClick: () => navigate(buildDomainListRelativeURL(parentPath, domainId, 'remove')), + }, + ] + : []), + ]; +}; + const DomainListDotMenu = ({ redirectSubPath, domainId, }: Pick & { domainId: OrganizationDomainResource['id']; }) => { - const { navigate } = useRouter(); - const { canDeleteDomain, canVerifyDomain } = useDomainList(); - - return ( - navigate(`${redirectSubPath}${domainId}/verify`), - }, - ] - : []), - ...(canDeleteDomain - ? [ - { - label: localizationKeys( - 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove', - ), - isDestructive: true, - onClick: () => navigate(`${redirectSubPath}${domainId}/remove`), - }, - ] - : []), - ]} - /> - ); + const actions = useMenuActions(redirectSubPath, domainId); + return ; }; export const DomainList = withGate( @@ -155,7 +160,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} diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabInvitations.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabInvitations.tsx index 7a57c04c7c3..a533393fe36 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 b46caafd9b7..a5078b639dd 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationMembersTabRequests.tsx @@ -57,7 +57,7 @@ export const OrganizationMembersTabRequests = () => { onClick={() => navigate('organization-settings/domain')} /> } - redirectSubPath={'organization-settings/domain/'} + 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 1681b673ec9..faca9fa90c3 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationSettings.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationSettings.tsx @@ -85,7 +85,7 @@ const OrganizationDomainsSection = () => { subtitle={localizationKeys('organizationProfile.profilePage.domainSection.subtitle')} id='organizationDomains' > - + { 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}`; From 01f9db67d1469233abc3ca9abea8a5c6bbfa0f82 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 18 Oct 2023 13:12:06 +0300 Subject: [PATCH 8/9] test(clerk-js): Test cases for trimLeadingSlash --- packages/clerk-js/src/utils/__tests__/url.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/clerk-js/src/utils/__tests__/url.test.ts b/packages/clerk-js/src/utils/__tests__/url.test.ts index cd4c075068c..b14a33e6b7d 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'); From 9a375d9aa94085adffa08fb3f692c0e89d80d7c1 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 25 Oct 2023 11:29:41 -0700 Subject: [PATCH 9/9] chore(clerk-js): Address Pr comments --- .../clerk-js/src/core/resources/Session.ts | 9 ++- .../OrganizationProfile/DomainList.tsx | 47 ++++++++------- .../__tests__/OrganizationMembers.test.tsx | 31 ++-------- .../__tests__/OrganizationSettings.test.tsx | 21 +++---- .../__tests__/OrganizationSwitcher.test.tsx | 44 +++++++------- .../clerk-js/src/ui/utils/test/mockHelpers.ts | 57 ++++--------------- 6 files changed, 72 insertions(+), 137 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 238fc734cdc..a7850e8000d 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -109,12 +109,15 @@ export class Session extends BaseResource implements SessionResource { if (params.any) { return resolve( - params.any.filter(permObj => { + !!params.any.find(permObj => { if (permObj.permission) { return activeOrganizationPermissions.includes(permObj.permission); } - return activeOrganizationRole === permObj.role; - }).length > 0, + if (permObj.role) { + return activeOrganizationRole === permObj.role; + } + return false; + }), ); } diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx index db0639f0001..2f158b7e230 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx @@ -5,6 +5,7 @@ import React, { useMemo } from 'react'; 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'; @@ -36,33 +37,31 @@ const useDomainList = () => { const buildDomainListRelativeURL = (parentPath: string, domainId: string, mode?: 'verify' | 'remove') => trimLeadingSlash(stripOrigin(toURL(`${parentPath}/${domainId}/${mode || ''}`))); -const useMenuActions = (parentPath: string, domainId: string) => { +const useMenuActions = ( + parentPath: string, + domainId: string, +): { label: LocalizationKey; onClick: () => Promise; isDestructive?: boolean }[] => { const { canDeleteDomain, canVerifyDomain } = useDomainList(); const { navigate } = useRouter(); - return [ - ...(canVerifyDomain - ? [ - { - label: localizationKeys( - 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__verify', - ), - onClick: () => navigate(buildDomainListRelativeURL(parentPath, domainId, 'verify')), - }, - ] - : []), - ...(canDeleteDomain - ? [ - { - label: localizationKeys( - 'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove', - ), - isDestructive: true, - onClick: () => navigate(buildDomainListRelativeURL(parentPath, domainId, 'remove')), - }, - ] - : []), - ]; + 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 = ({ diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx index 3eeb70f6d5b..99c991d4c9e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationMembers.test.tsx @@ -12,13 +12,11 @@ const { createFixtures } = bindCreateFixtures('OrganizationProfile'); describe('OrganizationMembers', () => { 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.dev'], 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.dev'], 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.dev'], 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.dev'], - 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.dev'], 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 66a1c0b13c0..18a0c3c97ea 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 @@ -2,7 +2,7 @@ import type { OrganizationDomainResource, OrganizationMembershipResource } from import { describe, it } from '@jest/globals'; import userEvent from '@testing-library/user-event'; -import { render, waitFor } from '../../../../testUtils'; +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(); @@ -94,7 +92,7 @@ describe('OrganizationSettings', () => { organization_memberships: [{ name: 'Org1', permissions: ['org:sys_memberships:read'] }], }); }); - const { queryByText } = render(, { wrapper }); + 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(); @@ -115,7 +113,7 @@ describe('OrganizationSettings', () => { total_count: 0, }), ); - const { queryByText } = render(, { wrapper }); + const { queryByText } = await act(() => render(, { wrapper })); await new Promise(r => setTimeout(r, 100)); expect(queryByText('Verified domains')).toBeInTheDocument(); @@ -124,7 +122,7 @@ describe('OrganizationSettings', () => { 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.dev'], @@ -132,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(); @@ -142,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.dev'], @@ -150,7 +147,6 @@ describe('OrganizationSettings', () => { }); }); - fixtures.clerk.session?.isAuthorized.mockResolvedValue(true); const { getByText } = render(, { wrapper }); await waitFor(() => { expect(getByText('Danger')).toBeDefined(); @@ -181,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(() => { @@ -210,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 })); @@ -230,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 51e4a8cba92..41e7435f64b 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.dev'] }); }); - 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.dev'] }); }); - 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.dev'] }); }); - 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.dev'] }); + f.withUser({ + email_addresses: ['test@clerk.dev'], + 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.dev'], 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.dev'], 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.dev'], 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.dev'], @@ -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/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts index 6744b98b6d7..e8dc6477cd9 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,51 +34,13 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked { - mockMethodsOf(session); - session.isAuthorized = jest.fn(args => { - return new Promise((resolve, reject) => { - // if there is no active organization user can not be authorized - if (!session.lastActiveOrganizationId || !session.user) { - return resolve(false); - } - - // loop through organizationMemberships from client piggybacking - const orgMemberships = session.user.organizationMemberships || []; - const activeMembership = orgMemberships.find(mem => mem.organization.id === session.lastActiveOrganizationId); - - // Based on FAPI this should never happen, but we handle it anyway - if (!activeMembership) { - return resolve(false); - } - - const activeOrganizationPermissions = activeMembership.permissions; - const activeOrganizationRole = activeMembership.role; - - if (args.permission) { - return resolve(activeOrganizationPermissions.includes(args.permission)); - } - if (args.role) { - return resolve(activeOrganizationRole === args.role); - } - - if (args.any) { - return resolve( - args.any.filter(permObj => { - if (permObj.permission) { - return activeOrganizationPermissions.includes(permObj.permission); - } - return activeOrganizationRole === permObj.role; - }).length > 0, - ); - } - - return reject(false); - }); + 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);