Skip to content

Commit

Permalink
Introduce internal Gate component (#1834)
Browse files Browse the repository at this point in the history
* feat(clerk-js,types): Introduce Session.isAuthorized

* feat(clerk-js): Introduce internal Gate component
  • Loading branch information
panteliselef authored Oct 9, 2023
1 parent 7f4d4b9 commit 997b8e2
Show file tree
Hide file tree
Showing 13 changed files with 219 additions and 9 deletions.
6 changes: 6 additions & 0 deletions .changeset/loud-foxes-love.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/proud-dolls-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Introduces an internal `<Gate/>` component (supporting hook and HOC) which enables us to conditionally render parts of our components based on a users permissions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
MembershipRole,
OrganizationMembershipJSON,
OrganizationMembershipResource,
OrganizationPermission,
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions packages/clerk-js/src/core/resources/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
33 changes: 33 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ActJWTClaim,
GetToken,
GetTokenOptions,
IsAuthorized,
SessionJSON,
SessionResource,
SessionStatus,
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ OrganizationMembership {
"updatedAt": 1970-01-01T00:01:07.890Z,
},
"pathRoot": "",
"permissions": [],
"publicMetadata": {
"foo": "bar",
},
Expand Down
19 changes: 15 additions & 4 deletions packages/clerk-js/src/core/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import type {
OAuthProvider,
OrganizationJSON,
OrganizationMembershipJSON,
OrganizationPermission,
PhoneNumberJSON,
UserJSON,
} from '@clerk/types';

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 };
type OrgParams = Partial<OrganizationJSON> & { role?: MembershipRole; permissions?: OrganizationPermission[] };

type WithUserParams = Omit<
Partial<UserJSON>,
Expand All @@ -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),
Expand All @@ -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;
};
Expand Down Expand Up @@ -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;
Expand Down
65 changes: 65 additions & 0 deletions packages/clerk-js/src/ui/common/Gate.tsx
Original file line number Diff line number Diff line change
@@ -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<IsAuthorized>[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<P>(Component: ComponentType<P>, gateProps: GateProps): React.ComponentType<P> {
const displayName = Component.displayName || Component.name || 'Component';
const HOC = (props: P) => {
return (
<Gate {...gateProps}>
<Component {...(props as any)} />
</Gate>
);
};

HOC.displayName = `withGate(${displayName})`;

return HOC;
}
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 4 additions & 4 deletions packages/clerk-js/src/ui/hooks/useFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ export const useFetch = <T>(
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(() => {
Expand Down
8 changes: 7 additions & 1 deletion packages/types/src/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions packages/types/src/organizationMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};
16 changes: 16 additions & 0 deletions packages/types/src/session.ts
Original file line number Diff line number Diff line change
@@ -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<IsAuthorizedReturnValues>;

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;
Expand All @@ -18,6 +30,10 @@ export interface SessionResource extends ClerkResource {
remove: () => Promise<SessionResource>;
touch: () => Promise<SessionResource>;
getToken: GetToken;
/**
* @experimental The method is experimental and subject to change in future releases.
*/
isAuthorized: IsAuthorized;
clearCache: () => void;
createdAt: Date;
updatedAt: Date;
Expand Down

0 comments on commit 997b8e2

Please sign in to comment.