Skip to content

Commit

Permalink
feat(clerk-js): Use org:sys_domains:read for improved fine grain co…
Browse files Browse the repository at this point in the history
…ntrol of the UI (#1988)

Backport of #1896
  • Loading branch information
panteliselef authored Oct 31, 2023
1 parent 3ba3f38 commit d37d44a
Show file tree
Hide file tree
Showing 19 changed files with 344 additions and 208 deletions.
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

0 comments on commit d37d44a

Please sign in to comment.