From 997b8e256c8f83d68d0ae4243c7ea5640573d1ae Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 9 Oct 2023 13:29:51 +0300 Subject: [PATCH] Introduce internal Gate component (#1834) * feat(clerk-js,types): Introduce Session.isAuthorized * feat(clerk-js): Introduce internal Gate component --- .changeset/loud-foxes-love.md | 6 ++ .changeset/proud-dolls-yawn.md | 5 ++ .../core/resources/OrganizationMembership.ts | 8 +++ .../src/core/resources/Session.test.ts | 42 ++++++++++++ .../clerk-js/src/core/resources/Session.ts | 33 ++++++++++ .../OrganizationMembership.test.ts.snap | 1 + packages/clerk-js/src/core/test/fixtures.ts | 19 ++++-- packages/clerk-js/src/ui/common/Gate.tsx | 65 +++++++++++++++++++ packages/clerk-js/src/ui/common/index.ts | 1 + packages/clerk-js/src/ui/hooks/useFetch.ts | 8 +-- packages/types/src/json.ts | 8 ++- packages/types/src/organizationMembership.ts | 16 +++++ packages/types/src/session.ts | 16 +++++ 13 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 .changeset/loud-foxes-love.md create mode 100644 .changeset/proud-dolls-yawn.md create mode 100644 packages/clerk-js/src/ui/common/Gate.tsx diff --git a/.changeset/loud-foxes-love.md b/.changeset/loud-foxes-love.md new file mode 100644 index 0000000000..a085c8cbcb --- /dev/null +++ b/.changeset/loud-foxes-love.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduces a new `isAuthorized()` method in the `Session` class. Returns a promise and checks whether the active user is allowed to perform an action based on the passed (required) permission and the ones attached to the membership. diff --git a/.changeset/proud-dolls-yawn.md b/.changeset/proud-dolls-yawn.md new file mode 100644 index 0000000000..921cada085 --- /dev/null +++ b/.changeset/proud-dolls-yawn.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Introduces an internal `` component (supporting hook and HOC) which enables us to conditionally render parts of our components based on a users permissions. diff --git a/packages/clerk-js/src/core/resources/OrganizationMembership.ts b/packages/clerk-js/src/core/resources/OrganizationMembership.ts index 5e48c91870..9686df2f2b 100644 --- a/packages/clerk-js/src/core/resources/OrganizationMembership.ts +++ b/packages/clerk-js/src/core/resources/OrganizationMembership.ts @@ -6,6 +6,7 @@ import type { MembershipRole, OrganizationMembershipJSON, OrganizationMembershipResource, + OrganizationPermission, } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; @@ -17,6 +18,12 @@ export class OrganizationMembership extends BaseResource implements Organization publicMetadata: OrganizationMembershipPublicMetadata = {}; publicUserData!: PublicUserData; organization!: Organization; + /** + * @experimental The property is experimental and subject to change in future releases. + */ + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + permissions: (OrganizationPermission | (string & {}))[] = []; role!: MembershipRole; createdAt!: Date; updatedAt!: Date; @@ -106,6 +113,7 @@ export class OrganizationMembership extends BaseResource implements Organization if (data.public_user_data) { this.publicUserData = new PublicUserData(data.public_user_data); } + this.permissions = Array.isArray(data.permissions) ? [...data.permissions] : []; this.role = data.role; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); diff --git a/packages/clerk-js/src/core/resources/Session.test.ts b/packages/clerk-js/src/core/resources/Session.test.ts index f1e8dfad4f..19b68fbee2 100644 --- a/packages/clerk-js/src/core/resources/Session.test.ts +++ b/packages/clerk-js/src/core/resources/Session.test.ts @@ -56,4 +56,46 @@ describe('Session', () => { expect(dispatchSpy.mock.calls[0]).toMatchSnapshot(); }); }); + + describe('isAuthorized()', () => { + it('user with permission to delete the organization should be able to delete the organization', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({ + organization_memberships: [{ name: 'Org1', id: 'org1' }], + }), + last_active_organization_id: 'org1', + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const isAuthorized = await session.isAuthorized({ permission: 'org:profile:delete' }); + + expect(isAuthorized).toBe(true); + }); + + it('user with permission to read memberships should not be deleting the organization', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({ + organization_memberships: [{ name: 'Org1', id: 'org1', permissions: ['org:memberships:read'] }], + }), + last_active_organization_id: 'org1', + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const isAuthorized = await session.isAuthorized({ permission: 'org:profile:delete' }); + + expect(isAuthorized).toBe(false); + }); + }); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 94e45efa2c..e058595487 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -4,6 +4,7 @@ import type { ActJWTClaim, GetToken, GetTokenOptions, + IsAuthorized, SessionJSON, SessionResource, SessionStatus, @@ -75,6 +76,38 @@ 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 => { + // if there is no active organization user can not be authorized + if (!this.lastActiveOrganizationId || !this.user) { + return resolve(false); + } + + // loop through organizationMemberships from client piggybacking + const orgMemberships = this.user.organizationMemberships || []; + const activeMembership = orgMemberships.find(mem => mem.organization.id === this.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 (params.permission) { + return resolve(activeOrganizationPermissions.includes(params.permission)); + } + if (params.role) { + return resolve(activeOrganizationRole === params.role); + } + return resolve(false); + }); + }; + #hydrateCache = (token: TokenResource | null) => { if (token) { SessionTokenCache.set({ diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap index d0d745fea4..1ab0ec074f 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap @@ -38,6 +38,7 @@ OrganizationMembership { "updatedAt": 1970-01-01T00:01:07.890Z, }, "pathRoot": "", + "permissions": [], "publicMetadata": { "foo": "bar", }, diff --git a/packages/clerk-js/src/core/test/fixtures.ts b/packages/clerk-js/src/core/test/fixtures.ts index 9cc89a8508..3baaff61aa 100644 --- a/packages/clerk-js/src/core/test/fixtures.ts +++ b/packages/clerk-js/src/core/test/fixtures.ts @@ -5,6 +5,7 @@ import type { OAuthProvider, OrganizationJSON, OrganizationMembershipJSON, + OrganizationPermission, PhoneNumberJSON, UserJSON, } from '@clerk/types'; @@ -12,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 }; +type OrgParams = Partial & { role?: MembershipRole; permissions?: OrganizationPermission[] }; type WithUserParams = Omit< Partial, @@ -26,8 +27,8 @@ type WithUserParams = Omit< export const getOrganizationId = (orgParams: OrgParams) => orgParams?.id || orgParams?.name || 'test_id'; -export const createOrganization = (params: OrgParams): OrganizationMembershipJSON => { - const { role, ...orgParams } = params; +export const createOrganizationMembership = (params: OrgParams): OrganizationMembershipJSON => { + const { role, permissions, ...orgParams } = params; return { created_at: new Date().getTime(), id: getOrganizationId(orgParams), @@ -49,6 +50,16 @@ export const createOrganization = (params: OrgParams): OrganizationMembershipJSO } as OrganizationJSON, public_metadata: {}, role: role || 'admin', + permissions: permissions || [ + 'org:domains:delete', + 'org:domains:manage', + 'org:domains:read', + 'org:memberships:delete', + 'org:memberships:manage', + 'org:memberships:read', + 'org:profile:delete', + 'org:profile:manage', + ], updated_at: new Date().getTime(), } as OrganizationMembershipJSON; }; @@ -145,7 +156,7 @@ export const createUser = (params: WithUserParams): UserJSON => { typeof p === 'string' ? createExternalAccount({ provider: p }) : createExternalAccount(p), ), organization_memberships: (params.organization_memberships || []).map(o => - typeof o === 'string' ? createOrganization({ name: o }) : createOrganization(o), + typeof o === 'string' ? createOrganizationMembership({ name: o }) : createOrganizationMembership(o), ), } as any as UserJSON; res.primary_email_address_id = res.email_addresses[0]?.id; diff --git a/packages/clerk-js/src/ui/common/Gate.tsx b/packages/clerk-js/src/ui/common/Gate.tsx new file mode 100644 index 0000000000..7da69f6b22 --- /dev/null +++ b/packages/clerk-js/src/ui/common/Gate.tsx @@ -0,0 +1,65 @@ +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 = Parameters[0]; +type GateProps = PropsWithChildren< + GateParams & { + fallback?: ReactNode; + redirectTo?: string; + } +>; + +export const useGate = (params: GateParams) => { + const { isAuthorized } = useCoreSession(); + const { data: isAuthorizedUser } = useFetch(isAuthorized, params); + + return { + isAuthorizedUser, + }; +}; + +export const Gate = (gateProps: GateProps) => { + const { children, fallback, redirectTo, ...restAuthorizedParams } = gateProps; + + const { isAuthorizedUser } = useGate(restAuthorizedParams); + + const { navigate } = useRouter(); + + useEffect(() => { + // wait for promise to resolve + if (typeof isAuthorizedUser === 'boolean' && !isAuthorizedUser && redirectTo) { + void navigate(redirectTo); + } + }, [isAuthorizedUser, redirectTo]); + + // wait for promise to resolve + if (typeof isAuthorizedUser === 'boolean' && !isAuthorizedUser && fallback) { + return <>{fallback}; + } + + if (isAuthorizedUser) { + return <>{children}; + } + + return null; +}; + +export function withGate

(Component: ComponentType

, gateProps: GateProps): React.ComponentType

{ + const displayName = Component.displayName || Component.name || 'Component'; + const HOC = (props: P) => { + return ( + + + + ); + }; + + HOC.displayName = `withGate(${displayName})`; + + return HOC; +} diff --git a/packages/clerk-js/src/ui/common/index.ts b/packages/clerk-js/src/ui/common/index.ts index bdd3c08c14..b08b98ac79 100644 --- a/packages/clerk-js/src/ui/common/index.ts +++ b/packages/clerk-js/src/ui/common/index.ts @@ -2,6 +2,7 @@ export * from './BlockButtons'; export * from './constants'; export * from './CalloutWithAction'; export * from './forms'; +export * from './Gate'; export * from './InfiniteListSpinner'; export * from './redirects'; export * from './verification'; diff --git a/packages/clerk-js/src/ui/hooks/useFetch.ts b/packages/clerk-js/src/ui/hooks/useFetch.ts index 514ac87f7e..f725b520bb 100644 --- a/packages/clerk-js/src/ui/hooks/useFetch.ts +++ b/packages/clerk-js/src/ui/hooks/useFetch.ts @@ -24,11 +24,11 @@ export const useFetch = ( requestStatus.setLoading(); fetcherRef .current(params) - .then(domain => { + .then(result => { requestStatus.setIdle(); - if (domain) { - setData({ ...domain }); - callbacks?.onSuccess?.({ ...domain }); + if (typeof result !== 'undefined') { + setData(typeof result === 'object' ? { ...result } : result); + callbacks?.onSuccess?.(typeof result === 'object' ? { ...result } : result); } }) .catch(() => { diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index aca6f80b7e..2a896ea8f1 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -8,7 +8,7 @@ import type { ActJWTClaim } from './jwt'; import type { OAuthProvider } from './oauth'; import type { OrganizationDomainVerificationStatus, OrganizationEnrollmentMode } from './organizationDomain'; import type { OrganizationInvitationStatus } from './organizationInvitation'; -import type { MembershipRole } from './organizationMembership'; +import type { MembershipRole, OrganizationPermission } from './organizationMembership'; import type { OrganizationSettingsJSON } from './organizationSettings'; import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { SamlIdpSlug } from './saml'; @@ -322,6 +322,12 @@ export interface OrganizationMembershipJSON extends ClerkResourceJSON { object: 'organization_membership'; id: string; organization: OrganizationJSON; + /** + * @experimental The property is experimental and subject to change in future releases. + */ + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + permissions: (OrganizationPermission | (string & {}))[]; public_metadata: OrganizationMembershipPublicMetadata; public_user_data: PublicUserDataJSON; role: MembershipRole; diff --git a/packages/types/src/organizationMembership.ts b/packages/types/src/organizationMembership.ts index 6e60ee9f20..8786088955 100644 --- a/packages/types/src/organizationMembership.ts +++ b/packages/types/src/organizationMembership.ts @@ -25,6 +25,12 @@ declare global { export interface OrganizationMembershipResource extends ClerkResource { id: string; organization: OrganizationResource; + /** + * @experimental The property is experimental and subject to change in future releases. + */ + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + permissions: (OrganizationPermission | (string & {}))[]; publicMetadata: OrganizationMembershipPublicMetadata; publicUserData: PublicUserData; role: MembershipRole; @@ -36,6 +42,16 @@ export interface OrganizationMembershipResource extends ClerkResource { export type MembershipRole = 'admin' | 'basic_member' | 'guest_member'; +export type OrganizationPermission = + | 'org:domains:manage' + | 'org:domains:delete' + | 'org:profile:manage' + | 'org:profile:delete' + | 'org:memberships:read' + | 'org:memberships:manage' + | 'org:memberships:delete' + | 'org:domains:read'; + export type UpdateOrganizationMembershipParams = { role: MembershipRole; }; diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 416acb2ed5..006dfc25dc 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -1,8 +1,20 @@ import type { ActJWTClaim } from './jwt'; +import type { OrganizationPermission } from './organizationMembership'; import type { ClerkResource } from './resource'; import type { TokenResource } from './token'; import type { UserResource } from './user'; +export type IsAuthorized = (isAuthorizedParams: IsAuthorizedParams) => Promise; + +interface IsAuthorizedParams { + // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string + // eslint-disable-next-line + permission?: OrganizationPermission | (string & {}); + role?: string; +} + +type IsAuthorizedReturnValues = boolean; + export interface SessionResource extends ClerkResource { id: string; status: SessionStatus; @@ -18,6 +30,10 @@ export interface SessionResource extends ClerkResource { remove: () => Promise; touch: () => Promise; getToken: GetToken; + /** + * @experimental The method is experimental and subject to change in future releases. + */ + isAuthorized: IsAuthorized; clearCache: () => void; createdAt: Date; updatedAt: Date;