Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
24 changes: 19 additions & 5 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null> => {
return runWithExponentialBackOff(() => this._getToken(options), {
shouldRetry: (error: unknown, currentIteration: number) => !is4xxError(error) && currentIteration < 4,
Expand All @@ -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);
Expand All @@ -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();
});
};

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,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';
Expand All @@ -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<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 +88,7 @@ export const DomainList = withGate(
},
});

const { showDotMenu } = useDomainList();
const { ref } = useInView({
threshold: 0,
onChange: inView => {
Expand Down Expand Up @@ -69,7 +127,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 +140,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 ? (
<DomainListDotMenu
redirectSubPath={redirectSubPath}
domainId={d.id}
/>
) : undefined
}
>
{d.name}
Expand All @@ -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}
</ArrowBlockButton>
Expand Down Expand Up @@ -154,6 +201,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