Skip to content
6 changes: 6 additions & 0 deletions .changeset/curly-news-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Shows list of domains if member has the `org:sys_domain:read` permission.
2 changes: 2 additions & 0 deletions .changeset/odd-lemons-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 17 additions & 2 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -106,7 +106,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();
});
};

Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrganizationJSON> & { role?: MembershipRole; permissions?: OrganizationPermission[] };
export type OrgParams = Partial<OrganizationJSON> & { role?: MembershipRole; permissions?: OrganizationPermission[] };

type WithUserParams = Omit<
Partial<UserJSON>,
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/ui/common/Gate.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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';

import { useCoreSession } from '../contexts';
import { useFetch } from '../hooks';
import { useRouter } from '../router';

type GateParams = Omit<Parameters<IsAuthorized>[0], 'permission'> & { permission: OrganizationPermission };
type GateParams = Parameters<IsAuthorized>[0];
type GateProps = PropsWithChildren<
GateParams & {
fallback?: ReactNode;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
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 { 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';
Expand All @@ -17,10 +19,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' });
Comment thread
SokratisVidros marked this conversation as resolved.
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<unknown>; 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<DomainListProps, 'redirectSubPath'> & {
domainId: OrganizationDomainResource['id'];
}) => {
const actions = useMenuActions(redirectSubPath, domainId);
return <ThreeDotsMenu actions={actions} />;
};

export const DomainList = withGate(
(props: DomainListProps) => {
const { verificationStatus, enrollmentMode, redirectSubPath, fallback, ...rest } = props;
Expand All @@ -31,6 +84,7 @@ export const DomainList = withGate(
},
});

const { showDotMenu } = useDomainList();
const { ref } = useInView({
threshold: 0,
onChange: inView => {
Expand Down Expand Up @@ -69,7 +123,7 @@ export const DomainList = withGate(
<Col>
{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 (
<BlockWithTrailingComponent
key={d.id}
Expand All @@ -82,23 +136,12 @@ export const DomainList = withGate(
})}
badge={<EnrollmentBadge organizationDomain={d} />}
trailingComponent={
<ThreeDotsMenu
actions={[
{
label: localizationKeys(
'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__verify',
),
onClick: () => navigate(`${redirectSubPath}${d.id}/verify`),
},
{
label: localizationKeys(
'organizationProfile.profilePage.domainSection.unverifiedDomain_menuAction__remove',
),
isDestructive: true,
onClick: () => navigate(`${redirectSubPath}${d.id}/remove`),
},
]}
/>
showDotMenu ? (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙃 showDotMeny && <JSX/>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already tried that, seems like trailingComponent does not accept null, I didn't want to mess with the signature of the component.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we update its types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following has type of false | EmotionJSX.Element | null

showDotMenu && (
                    <DomainListDotMenu
                      redirectSubPath={redirectSubPath}
                      domainId={d.id}
                    />
                  )

So i would have to do

(showDotMenu  || null ) && (
                    <DomainListDotMenu
                      redirectSubPath={redirectSubPath}
                      domainId={d.id}
                    />
                  )

Which i'm not convinced it is better

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I understand what you are talking about. Is this the type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the TS error
image

<DomainListDotMenu
redirectSubPath={redirectSubPath}
domainId={d.id}
/>
) : undefined
}
>
{d.name}
Expand All @@ -116,7 +159,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}
</ArrowBlockButton>
Expand Down Expand Up @@ -154,6 +197,6 @@ export const DomainList = withGate(
);
},
{
permission: 'org:sys_domains:manage',
permission: 'org:sys_domains:read',
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<Col
gap={8}
Expand All @@ -20,42 +23,47 @@ export const OrganizationMembersTabRequests = () => {
}}
>
{__unstable_manageBillingUrl && <MembershipWidget />}
<Col
gap={2}
sx={{
width: '100%',
}}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys(
'organizationProfile.membersPage.requestsTab.autoSuggestions.headerTitle',
)}
textVariant='largeMedium'
/>
<Header.Subtitle
localizationKey={localizationKeys(
'organizationProfile.membersPage.requestsTab.autoSuggestions.headerSubtitle',
)}
variant='regularRegular'
/>
</Header.Root>
<DomainList
fallback={
<BlockButton
colorScheme='primary'
textLocalizationKey={localizationKeys(
'organizationProfile.membersPage.requestsTab.autoSuggestions.primaryButton',
)}
id='manageVerifiedDomains'
onClick={() => navigate('organization-settings/domain')}

{isDomainsEnabled && (
<Gate permission='org:sys_domains:manage'>
<Col
gap={2}
sx={{
width: '100%',
}}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys(
'organizationProfile.membersPage.requestsTab.autoSuggestions.headerTitle',
)}
textVariant='largeMedium'
/>
<Header.Subtitle
localizationKey={localizationKeys(
'organizationProfile.membersPage.requestsTab.autoSuggestions.headerSubtitle',
)}
variant='regularRegular'
/>
</Header.Root>
<DomainList
fallback={
<BlockButton
colorScheme='primary'
textLocalizationKey={localizationKeys(
'organizationProfile.membersPage.requestsTab.autoSuggestions.primaryButton',
)}
id='manageVerifiedDomains'
onClick={() => navigate('organization-settings/domain')}
/>
}
redirectSubPath={'organization-settings/domain'}
verificationStatus={'verified'}
enrollmentMode={'automatic_suggestion'}
/>
}
redirectSubPath={'organization-settings/domain/'}
verificationStatus={'verified'}
enrollmentMode={'automatic_suggestion'}
/>
</Col>
</Col>
</Gate>
)}

<Flex
direction='col'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof Profile
</Route>
<Route path=':id'>
<Gate
permission={'org:sys_domains:manage'}
any={[{ permission: 'org:sys_domains:manage' }, { permission: 'org:sys_domains:delete' }]}
redirectTo='../../'
>
<VerifiedDomainPage />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const OrganizationSettings = () => {
<Header.Subtitle localizationKey={localizationKeys('organizationProfile.start.headerSubtitle__settings')} />
</Header.Root>
<OrganizationProfileSection />
<Gate permission='org:sys_domains:manage'>
<Gate permission='org:sys_domains:read'>
<OrganizationDomainsSection />
</Gate>
<OrganizationDangerSection />
Expand Down Expand Up @@ -85,7 +85,7 @@ const OrganizationDomainsSection = () => {
subtitle={localizationKeys('organizationProfile.profilePage.domainSection.subtitle')}
id='organizationDomains'
>
<DomainList redirectSubPath={'domain/'} />
<DomainList redirectSubPath={'domain'} />

<AddBlockButton
textLocalizationKey={localizationKeys('organizationProfile.profilePage.domainSection.primaryButton')}
Expand Down
Loading