Skip to content

Commit

Permalink
feat(nextjs,clerk-react,types,clerk-js): Add experimental support for…
Browse files Browse the repository at this point in the history
… `<Gate/>` (#1942) (#2051)

* feat(nextjs,clerk-react,types,clerk-js): Add experimental support for `<Gate/>`

* chore(clerk-js): Remove references to permissions

* fix(clerk-js): Edit internal Gate to use some instead of any

* chore(clerk-js): Enable tests

* chore(clerk-js): Add changeset

* chore(types): Create experimental types

* feat(nextjs): Export `<Experimental__Gate/>`

* chore(chrome-extension): Update snapshot
  • Loading branch information
panteliselef authored Nov 6, 2023
1 parent 20eab83 commit f9d1bc7
Show file tree
Hide file tree
Showing 19 changed files with 257 additions and 67 deletions.
10 changes: 10 additions & 0 deletions .changeset/orange-pumpkins-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/chrome-extension': minor
'@clerk/clerk-js': minor
'@clerk/backend': minor
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

Experimental support for `<Gate/>` with role checks.
60 changes: 58 additions & 2 deletions packages/backend/src/tokens/authObjects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { deprecated } from '@clerk/shared/deprecated';
import type { ActClaim, JwtPayload, ServerGetToken, ServerGetTokenOptions } from '@clerk/types';
import type {
ActClaim,
experimental__CheckAuthorizationWithoutPermission,
JwtPayload,
ServerGetToken,
ServerGetTokenOptions,
} from '@clerk/types';

import type { Organization, Session, User } from '../api';
import { createBackendApiClient } from '../api';
Expand Down Expand Up @@ -36,6 +42,10 @@ export type SignedInAuthObject = {
orgSlug: string | undefined;
organization: Organization | undefined;
getToken: ServerGetToken;
/**
* @experimental The method is experimental and subject to change in future releases.
*/
experimental__has: experimental__CheckAuthorizationWithoutPermission;
debug: AuthObjectDebug;
};

Expand All @@ -51,6 +61,10 @@ export type SignedOutAuthObject = {
orgSlug: null;
organization: null;
getToken: ServerGetToken;
/**
* @experimental The method is experimental and subject to change in future releases.
*/
experimental__has: experimental__CheckAuthorizationWithoutPermission;
debug: AuthObjectDebug;
};

Expand Down Expand Up @@ -110,6 +124,7 @@ export function signedInAuthObject(
orgSlug,
organization,
getToken,
experimental__has: createHasAuthorization({ orgId, orgRole, userId }),
debug: createDebug({ ...options, ...debugData }),
};
}
Expand All @@ -131,11 +146,21 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA
orgSlug: null,
organization: null,
getToken: () => Promise.resolve(null),
experimental__has: () => false,
debug: createDebug(debugData),
};
}

export function prunePrivateMetadata(resource?: { private_metadata: any } | { privateMetadata: any } | null) {
export function prunePrivateMetadata(
resource?:
| {
private_metadata: any;
}
| {
privateMetadata: any;
}
| null,
) {
// Delete sensitive private metadata from resource before rendering in SSR
if (resource) {
// @ts-ignore
Expand Down Expand Up @@ -190,3 +215,34 @@ const createGetToken: CreateGetToken = params => {
return sessionToken;
};
};

const createHasAuthorization =
({
orgId,
orgRole,
userId,
}: {
userId: string;
orgId: string | undefined;
orgRole: string | undefined;
}): experimental__CheckAuthorizationWithoutPermission =>
params => {
if (!orgId || !userId) {
return false;
}

if (params.role) {
return orgRole === params.role;
}

if (params.some) {
return !!params.some.find(permObj => {
if (permObj.role) {
return orgRole === permObj.role;
}
return false;
});
}

return false;
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ exports[`public exports should not include a breaking change 1`] = `
"ClerkProvider",
"CreateOrganization",
"EmailLinkErrorCode",
"Experimental__Gate",
"MagicLinkErrorCode",
"MultisessionAppSupport",
"OrganizationList",
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/src/core/resources/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('Session', () => {
updated_at: new Date().getTime(),
} as SessionJSON);

const isAuthorized = await session.isAuthorized({ permission: 'org:sys_profile:delete' });
const isAuthorized = await session.experimental__checkAuthorization({ permission: 'org:sys_profile:delete' });

expect(isAuthorized).toBe(true);
});
Expand All @@ -93,7 +93,7 @@ describe('Session', () => {
updated_at: new Date().getTime(),
} as SessionJSON);

const isAuthorized = await session.isAuthorized({ permission: 'org:sys_profile:delete' });
const isAuthorized = await session.experimental__checkAuthorization({ permission: 'org:sys_profile:delete' });

expect(isAuthorized).toBe(false);
});
Expand Down
72 changes: 33 additions & 39 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { runWithExponentialBackOff } from '@clerk/shared';
import { is4xxError } from '@clerk/shared/error';
import type {
ActJWTClaim,
CheckAuthorization,
GetToken,
GetTokenOptions,
IsAuthorized,
SessionJSON,
SessionResource,
SessionStatus,
Expand Down Expand Up @@ -70,8 +70,6 @@ export class Session extends BaseResource implements SessionResource {
return SessionTokenCache.clear();
};

// 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 @@ -81,48 +79,44 @@ 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, reject) => {
// if there is no active organization user can not be authorized
if (!this.lastActiveOrganizationId || !this.user) {
return resolve(false);
}
experimental__checkAuthorization: CheckAuthorization = params => {
// if there is no active organization user can not be authorized
if (!this.lastActiveOrganizationId || !this.user) {
return false;
}

// loop through organizationMemberships from client piggybacking
const orgMemberships = this.user.organizationMemberships || [];
const activeMembership = orgMemberships.find(mem => mem.organization.id === this.lastActiveOrganizationId);
// 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);
}
// Based on FAPI this should never happen, but we handle it anyway
if (!activeMembership) {
return false;
}

const activeOrganizationPermissions = activeMembership.permissions;
const activeOrganizationRole = activeMembership.role;
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);
}
if (params.permission) {
return activeOrganizationPermissions.includes(params.permission);
}
if (params.role) {
return activeOrganizationRole === params.role;
}

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;
}),
);
}
if (params.some) {
return !!params.some.find(permObj => {
if (permObj.permission) {
return activeOrganizationPermissions.includes(permObj.permission);
}
if (permObj.role) {
return activeOrganizationRole === permObj.role;
}
return false;
});
}

return reject();
});
return false;
};

#hydrateCache = (token: TokenResource | null) => {
Expand Down
10 changes: 4 additions & 6 deletions packages/clerk-js/src/ui/common/Gate.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type { IsAuthorized } from '@clerk/types';
import type { CheckAuthorization } 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 GateParams = Parameters<CheckAuthorization>[0];
type GateProps = PropsWithChildren<
GateParams & {
fallback?: ReactNode;
Expand All @@ -15,11 +14,10 @@ type GateProps = PropsWithChildren<
>;

export const useGate = (params: GateParams) => {
const { isAuthorized } = useCoreSession();
const { data: isAuthorizedUser } = useFetch(isAuthorized, params);
const { experimental__checkAuthorization } = useCoreSession();

return {
isAuthorizedUser,
isAuthorizedUser: experimental__checkAuthorization(params),
};
};

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
any={[{ permission: 'org:sys_domains:manage' }, { permission: 'org:sys_domains:delete' }]}
some={[{ permission: 'org:sys_domains:manage' }, { permission: 'org:sys_domains:delete' }]}
redirectTo='../../'
>
<VerifiedDomainPage />
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/utils/test/mockHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked<LoadedClerk
mockMethodsOf(clerk.client.signUp);
clerk.client.sessions.forEach(session => {
mockMethodsOf(session, {
exclude: ['isAuthorized'],
exclude: ['experimental__checkAuthorization'],
});
mockMethodsOf(session.user);
session.user?.emailAddresses.forEach(m => mockMethodsOf(m));
Expand Down
37 changes: 37 additions & 0 deletions packages/nextjs/src/app-router/server/controlComponents.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { experimental__CheckAuthorizationWithoutPermission } from '@clerk/types';
import { redirect } from 'next/navigation';
import React from 'react';

import { auth } from './auth';
Expand All @@ -13,3 +15,38 @@ export function SignedOut(props: React.PropsWithChildren) {
const { userId } = auth();
return userId ? null : <>{children}</>;
}

type GateServerComponentProps = React.PropsWithChildren<
Parameters<experimental__CheckAuthorizationWithoutPermission>[0] & {
fallback?: React.ReactNode;
redirectTo?: string;
}
>;

/**
* @experimental The component is experimental and subject to change in future releases.
*/
export function experimental__Gate(gateProps: GateServerComponentProps) {
const { children, fallback, redirectTo, ...restAuthorizedParams } = gateProps;
const { experimental__has } = auth();

const isAuthorizedUser = experimental__has(restAuthorizedParams);

const handleFallback = () => {
if (!redirectTo && !fallback) {
throw new Error('Provide `<Gate />` with a `fallback` or `redirectTo`');
}

if (redirectTo) {
return redirect(redirectTo);
}

return <>{fallback}</>;
};

if (!isAuthorizedUser) {
return handleFallback();
}

return <>{children}</>;
}
1 change: 1 addition & 0 deletions packages/nextjs/src/client-boundary/controlComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
ClerkLoading,
SignedOut,
SignedIn,
Experimental__Gate,
RedirectToSignIn,
RedirectToSignUp,
RedirectToUserProfile,
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/components.client.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { ClerkProvider } from './client-boundary/ClerkProvider';
export { SignedIn, SignedOut } from './client-boundary/controlComponents';
export { SignedIn, SignedOut, Experimental__Gate } from './client-boundary/controlComponents';
5 changes: 3 additions & 2 deletions packages/nextjs/src/components.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ClerkProvider } from './app-router/server/ClerkProvider';
import { SignedIn, SignedOut } from './app-router/server/controlComponents';
import { experimental__Gate, SignedIn, SignedOut } from './app-router/server/controlComponents';

export { ClerkProvider, SignedOut, SignedIn };
export { ClerkProvider, SignedOut, SignedIn, experimental__Gate as Experimental__Gate };

export type ServerComponentsServerModuleTypes = {
ClerkProvider: typeof ClerkProvider;
SignedIn: typeof SignedIn;
SignedOut: typeof SignedOut;
Experimental__Gate: typeof experimental__Gate;
};
6 changes: 6 additions & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export const ClerkProvider = ComponentsModule.ClerkProvider as ServerComponentsS
export const SignedIn = ComponentsModule.SignedIn as ServerComponentsServerModuleTypes['SignedIn'];
export const SignedOut = ComponentsModule.SignedOut as ServerComponentsServerModuleTypes['SignedOut'];

/**
* @experimental
*/
export const Experimental__Gate =
ComponentsModule.Experimental__Gate as ServerComponentsServerModuleTypes['Experimental__Gate'];

export const auth = ServerHelperModule.auth as ServerHelpersServerModuleTypes['auth'];
export const currentUser = ServerHelperModule.currentUser as ServerHelpersServerModuleTypes['currentUser'];
// export const getAuth = ServerHelperModule.getAuth as ServerHelpersServerModuleTypes['getAuth'];
Expand Down
Loading

0 comments on commit f9d1bc7

Please sign in to comment.