diff --git a/.changeset/short-eagles-search.md b/.changeset/short-eagles-search.md new file mode 100644 index 0000000000..956ec761ab --- /dev/null +++ b/.changeset/short-eagles-search.md @@ -0,0 +1,20 @@ +--- +'@clerk/chrome-extension': minor +'@clerk/clerk-js': minor +'@clerk/backend': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introduce Protect for authorization. +Changes in public APIs: +- Rename Gate to Protect +- Support for permission checks. (Previously only roles could be used) +- Remove the `experimental` tags and prefixes +- Drop `some` from the `has` utility and Protect. Protect now accepts a `condition` prop where a function is expected with the `has` being exposed as the param. +- Protect can now be used without required props. In this case behaves as ``, if no authorization props are passed. +- `has` will throw an error if neither `permission` or `role` is passed. +- `auth().protect()` for Nextjs App Router. Allow per page protection in app router. This utility will automatically throw a 404 error if user is not authorized or authenticated. + - inside a page or layout file it will render the nearest `not-found` component set by the developer + - inside a route handler it will return empty response body with a 404 status code diff --git a/packages/backend/src/tokens/authObjects.ts b/packages/backend/src/tokens/authObjects.ts index 1822b9c207..9a37fcdab5 100644 --- a/packages/backend/src/tokens/authObjects.ts +++ b/packages/backend/src/tokens/authObjects.ts @@ -1,8 +1,10 @@ import { deprecated } from '@clerk/shared/deprecated'; import type { ActClaim, - experimental__CheckAuthorizationWithoutPermission, + CheckAuthorizationWithCustomPermissions, JwtPayload, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, ServerGetToken, ServerGetTokenOptions, } from '@clerk/types'; @@ -38,14 +40,12 @@ export type SignedInAuthObject = { userId: string; user: User | undefined; orgId: string | undefined; - orgRole: string | undefined; + orgRole: OrganizationCustomRoleKey | undefined; orgSlug: string | undefined; + orgPermissions: OrganizationCustomPermissionKey[] | undefined; organization: Organization | undefined; getToken: ServerGetToken; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__has: experimental__CheckAuthorizationWithoutPermission; + has: CheckAuthorizationWithCustomPermissions; debug: AuthObjectDebug; }; @@ -59,12 +59,10 @@ export type SignedOutAuthObject = { orgId: null; orgRole: null; orgSlug: null; + orgPermissions: null; organization: null; getToken: ServerGetToken; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__has: experimental__CheckAuthorizationWithoutPermission; + has: CheckAuthorizationWithCustomPermissions; debug: AuthObjectDebug; }; @@ -91,6 +89,7 @@ export function signedInAuthObject( org_id: orgId, org_role: orgRole, org_slug: orgSlug, + org_permissions: orgPermissions, sub: userId, } = sessionClaims; const { apiKey, secretKey, apiUrl, apiVersion, token, session, user, organization } = options; @@ -122,9 +121,10 @@ export function signedInAuthObject( orgId, orgRole, orgSlug, + orgPermissions, organization, getToken, - experimental__has: createHasAuthorization({ orgId, orgRole, userId }), + has: createHasAuthorization({ orgId, orgRole, orgPermissions, userId }), debug: createDebug({ ...options, ...debugData }), }; } @@ -144,9 +144,10 @@ export function signedOutAuthObject(debugData?: AuthObjectDebugData): SignedOutA orgId: null, orgRole: null, orgSlug: null, + orgPermissions: null, organization: null, getToken: () => Promise.resolve(null), - experimental__has: () => false, + has: () => false, debug: createDebug(debugData), }; } @@ -192,7 +193,7 @@ export function sanitizeAuthObject>(authObject: T): T export const makeAuthObjectSerializable = >(obj: T): T => { // remove any non-serializable props from the returned object - const { debug, getToken, experimental__has, ...rest } = obj as unknown as AuthObject; + const { debug, getToken, has, ...rest } = obj as unknown as AuthObject; return rest as unknown as T; }; @@ -221,27 +222,30 @@ const createHasAuthorization = orgId, orgRole, userId, + orgPermissions, }: { userId: string; orgId: string | undefined; orgRole: string | undefined; - }): experimental__CheckAuthorizationWithoutPermission => + orgPermissions: string[] | undefined; + }): CheckAuthorizationWithCustomPermissions => params => { - if (!orgId || !userId) { + if (!params?.permission && !params?.role) { + throw new Error( + 'Missing parameters. `has` from `auth` or `getAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"`', + ); + } + + if (!orgId || !userId || !orgRole || !orgPermissions) { return false; } - if (params.role) { - return orgRole === params.role; + if (params.permission) { + return orgPermissions.includes(params.permission); } - if (params.some) { - return !!params.some.find(permObj => { - if (permObj.role) { - return orgRole === permObj.role; - } - return false; - }); + if (params.role) { + return orgRole === params.role; } return false; diff --git a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap index 3ac0e70526..305b960872 100644 --- a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap @@ -8,12 +8,12 @@ exports[`public exports should not include a breaking change 1`] = ` "ClerkProvider", "CreateOrganization", "EmailLinkErrorCode", - "Experimental__Gate", "MagicLinkErrorCode", "MultisessionAppSupport", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", + "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", diff --git a/packages/clerk-js/src/core/resources/OrganizationMembership.ts b/packages/clerk-js/src/core/resources/OrganizationMembership.ts index 0a4c188f6e..376df78867 100644 --- a/packages/clerk-js/src/core/resources/OrganizationMembership.ts +++ b/packages/clerk-js/src/core/resources/OrganizationMembership.ts @@ -6,7 +6,7 @@ import type { MembershipRole, OrganizationMembershipJSON, OrganizationMembershipResource, - OrganizationPermission, + OrganizationPermissionKey, } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; @@ -18,12 +18,7 @@ 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 & {}))[] = []; + permissions: OrganizationPermissionKey[] = []; role!: MembershipRole; createdAt!: Date; updatedAt!: Date; diff --git a/packages/clerk-js/src/core/resources/Session.test.ts b/packages/clerk-js/src/core/resources/Session.test.ts index b3f5c889d8..f705632e76 100644 --- a/packages/clerk-js/src/core/resources/Session.test.ts +++ b/packages/clerk-js/src/core/resources/Session.test.ts @@ -73,7 +73,7 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - const isAuthorized = await session.experimental__checkAuthorization({ permission: 'org:sys_profile:delete' }); + const isAuthorized = await session.checkAuthorization({ permission: 'org:sys_profile:delete' }); expect(isAuthorized).toBe(true); }); @@ -93,7 +93,7 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - const isAuthorized = await session.experimental__checkAuthorization({ permission: 'org:sys_profile:delete' }); + const isAuthorized = await session.checkAuthorization({ permission: 'org:sys_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 ef04429b66..220a39c2cb 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -76,10 +76,7 @@ export class Session extends BaseResource implements SessionResource { }); }; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__checkAuthorization: CheckAuthorization = params => { + checkAuthorization: CheckAuthorization = params => { // if there is no active organization user can not be authorized if (!this.lastActiveOrganizationId || !this.user) { return false; @@ -104,18 +101,6 @@ export class Session extends BaseResource implements SessionResource { return activeOrganizationRole === params.role; } - 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 false; }; diff --git a/packages/clerk-js/src/ui/common/Gate.tsx b/packages/clerk-js/src/ui/common/Gate.tsx index 0c1a016742..0134a6c777 100644 --- a/packages/clerk-js/src/ui/common/Gate.tsx +++ b/packages/clerk-js/src/ui/common/Gate.tsx @@ -1,30 +1,62 @@ -import type { CheckAuthorization } from '@clerk/types'; +import type { CheckAuthorization, MembershipRole, OrganizationPermissionKey } from '@clerk/types'; import type { ComponentType, PropsWithChildren, ReactNode } from 'react'; import React, { useEffect } from 'react'; import { useCoreSession } from '../contexts'; import { useRouter } from '../router'; -type GateParams = Parameters[0]; +type GateParams = Parameters[0] | ((has: CheckAuthorization) => boolean); type GateProps = PropsWithChildren< - GateParams & { + ( + | { + condition?: never; + role: MembershipRole; + permission?: never; + } + | { + condition?: never; + role?: never; + permission: OrganizationPermissionKey; + } + | { + condition: (has: CheckAuthorization) => boolean; + role?: never; + permission?: never; + } + ) & { fallback?: ReactNode; redirectTo?: string; } >; export const useGate = (params: GateParams) => { - const { experimental__checkAuthorization } = useCoreSession(); + const { checkAuthorization, id } = useCoreSession(); + + if (!id) { + return { isAuthorizedUser: false }; + } + + /** + * if a function is passed and returns false then throw not found + */ + if (typeof params === 'function') { + if (params(checkAuthorization)) { + return { isAuthorizedUser: true }; + } + return { isAuthorizedUser: false }; + } return { - isAuthorizedUser: experimental__checkAuthorization(params), + isAuthorizedUser: checkAuthorization(params), }; }; export const Gate = (gateProps: GateProps) => { const { children, fallback, redirectTo, ...restAuthorizedParams } = gateProps; - const { isAuthorizedUser } = useGate(restAuthorizedParams); + const { isAuthorizedUser } = useGate( + typeof restAuthorizedParams.condition === 'function' ? restAuthorizedParams.condition : restAuthorizedParams, + ); const { navigate } = useRouter(); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx index a4aa8e013d..1a848ff53f 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx @@ -12,16 +12,12 @@ export const OrganizationProfileNavbar = ( const { organization } = useCoreOrganization(); const { pages } = useOrganizationProfileContext(); - const { isAuthorizedUser: allowMembersRoute } = useGate({ - some: [ - { + const { isAuthorizedUser: allowMembersRoute } = useGate( + has => + has({ permission: 'org:sys_memberships:read', - }, - { - permission: 'org:sys_memberships:manage', - }, - ], - }); + }) || has({ permission: 'org:sys_memberships:manage' }), + ); if (!organization) { return null; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 3a56d86fda..ed257be5c4 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -77,7 +77,7 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent @@ -130,7 +130,9 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent + has({ permission: 'org:sys_memberships:read' }) || has({ permission: 'org:sys_memberships:manage' }) + } redirectTo={isSettingsPageRoot ? '../' : './organization-settings'} > diff --git a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts index 8f4d5e38ac..c509469eff 100644 --- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts @@ -35,7 +35,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked { mockMethodsOf(session, { - exclude: ['experimental__checkAuthorization'], + exclude: ['checkAuthorization'], }); mockMethodsOf(session.user); session.user?.emailAddresses.forEach(m => mockMethodsOf(m)); diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index 8d8563a90a..44d87094a2 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -1,12 +1,80 @@ +import type { SignedInAuthObject, SignedOutAuthObject } from '@clerk/backend'; +import type { CheckAuthorizationWithCustomPermissions } from '@clerk/types'; +import { notFound } from 'next/navigation'; + import { authAuthHeaderMissing } from '../../server/errors'; import { buildClerkProps, createGetAuth } from '../../server/getAuth'; +import type { AuthObjectWithDeprecatedResources } from '../../server/types'; import { buildRequestLike } from './utils'; +type AuthSignedIn = AuthObjectWithDeprecatedResources< + SignedInAuthObject & { + /** + * @experimental + * This function is experimental as it throws a Nextjs notFound error if user is not authenticated or authorized. + * In the future we would investigate a way to throw a more appropriate error that clearly describes the not authorized of authenticated status. + */ + protect: ( + params?: + | Parameters[0] + | ((has: CheckAuthorizationWithCustomPermissions) => boolean), + ) => AuthObjectWithDeprecatedResources; + } +>; + +type AuthSignedOut = AuthObjectWithDeprecatedResources< + SignedOutAuthObject & { + /** + * @experimental + * This function is experimental as it throws a Nextjs notFound error if user is not authenticated or authorized. + * In the future we would investigate a way to throw a more appropriate error that clearly describes the not authorized of authenticated status. + */ + protect: never; + } +>; + export const auth = () => { - return createGetAuth({ + const authObject = createGetAuth({ debugLoggerName: 'auth()', noAuthStatusMessage: authAuthHeaderMissing(), })(buildRequestLike()); + + (authObject as AuthSignedIn).protect = params => { + /** + * User is not authenticated + */ + if (!authObject.userId) { + notFound(); + } + + /** + * User is authenticated + */ + if (!params) { + return { ...authObject }; + } + + /** + * if a function is passed and returns false then throw not found + */ + if (typeof params === 'function') { + if (params(authObject.has)) { + return { ...authObject }; + } + notFound(); + } + + /** + * Checking if user is authorized when permission or role is passed + */ + if (authObject.has(params)) { + return { ...authObject }; + } + + notFound(); + }; + + return authObject as AuthSignedIn | AuthSignedOut; }; export const initialState = () => { diff --git a/packages/nextjs/src/app-router/server/controlComponents.tsx b/packages/nextjs/src/app-router/server/controlComponents.tsx index ac506a1ec8..7455bb5769 100644 --- a/packages/nextjs/src/app-router/server/controlComponents.tsx +++ b/packages/nextjs/src/app-router/server/controlComponents.tsx @@ -1,5 +1,4 @@ -import type { experimental__CheckAuthorizationWithoutPermission } from '@clerk/types'; -import { redirect } from 'next/navigation'; +import type { Protect as ProtectClientComponent } from '@clerk/clerk-react'; import React from 'react'; import { auth } from './auth'; @@ -16,37 +15,55 @@ export function SignedOut(props: React.PropsWithChildren) { return userId ? null : <>{children}; } -type GateServerComponentProps = React.PropsWithChildren< - Parameters[0] & { - fallback?: React.ReactNode; - redirectTo?: string; - } ->; +type ProtectServerComponentProps = React.ComponentProps; /** - * @experimental The component is experimental and subject to change in future releases. + * Use `` in order to prevent unauthenticated or unauthorized users from accessing the children passed to the component. + * + * Examples: + * ``` + * + * + * has({permission:"a_permission_key"})} /> + * has({role:"a_role_key"})} /> + * Unauthorized

} /> + * ``` */ -export function experimental__Gate(gateProps: GateServerComponentProps) { - const { children, fallback, redirectTo, ...restAuthorizedParams } = gateProps; - const { experimental__has } = auth(); +export function Protect(props: ProtectServerComponentProps) { + const { children, fallback, ...restAuthorizedParams } = props; + const { has, userId } = auth(); - const isAuthorizedUser = experimental__has(restAuthorizedParams); + /** + * Fallback to UI provided by user or `null` if authorization checks failed + */ + const unauthorized = <>{fallback ?? null}; - const handleFallback = () => { - if (!redirectTo && !fallback) { - throw new Error('Provide `` with a `fallback` or `redirectTo`'); - } + const authorized = <>{children}; - if (redirectTo) { - return redirect(redirectTo); - } + if (!userId) { + return unauthorized; + } - return <>{fallback}; - }; + /** + * Check against the results of `has` called inside the callback + */ + if (typeof restAuthorizedParams.condition === 'function') { + if (restAuthorizedParams.condition(has)) { + return authorized; + } + return unauthorized; + } - if (!isAuthorizedUser) { - return handleFallback(); + if (restAuthorizedParams.role || restAuthorizedParams.permission) { + if (has(restAuthorizedParams)) { + return authorized; + } + return unauthorized; } - return <>{children}; + /** + * If neither of the authorization params are passed behave as the ``. + * If fallback is present render that instead of rendering nothing. + */ + return authorized; } diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index 2ce6bb6333..58eec00a1f 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -5,7 +5,7 @@ export { ClerkLoading, SignedOut, SignedIn, - Experimental__Gate, + Protect, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, diff --git a/packages/nextjs/src/components.client.ts b/packages/nextjs/src/components.client.ts index 3bdb446014..aac3f82f65 100644 --- a/packages/nextjs/src/components.client.ts +++ b/packages/nextjs/src/components.client.ts @@ -1,2 +1,2 @@ export { ClerkProvider } from './client-boundary/ClerkProvider'; -export { SignedIn, SignedOut, Experimental__Gate } from './client-boundary/controlComponents'; +export { SignedIn, SignedOut, Protect } from './client-boundary/controlComponents'; diff --git a/packages/nextjs/src/components.server.ts b/packages/nextjs/src/components.server.ts index e002e1b086..f73c8cc91c 100644 --- a/packages/nextjs/src/components.server.ts +++ b/packages/nextjs/src/components.server.ts @@ -1,11 +1,11 @@ import { ClerkProvider } from './app-router/server/ClerkProvider'; -import { experimental__Gate, SignedIn, SignedOut } from './app-router/server/controlComponents'; +import { Protect, SignedIn, SignedOut } from './app-router/server/controlComponents'; -export { ClerkProvider, SignedOut, SignedIn, experimental__Gate as Experimental__Gate }; +export { ClerkProvider, SignedOut, SignedIn, Protect }; export type ServerComponentsServerModuleTypes = { ClerkProvider: typeof ClerkProvider; SignedIn: typeof SignedIn; SignedOut: typeof SignedOut; - Experimental__Gate: typeof experimental__Gate; + Protect: typeof Protect; }; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 6b4c171424..f1e4a262b7 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -93,11 +93,7 @@ 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 Protect = ComponentsModule.Protect as ServerComponentsServerModuleTypes['Protect']; export const auth = ServerHelperModule.auth as ServerHelpersServerModuleTypes['auth']; export const currentUser = ServerHelperModule.currentUser as ServerHelpersServerModuleTypes['currentUser']; diff --git a/packages/nextjs/src/server/getAuth.ts b/packages/nextjs/src/server/getAuth.ts index a3df8051c6..0397268d6d 100644 --- a/packages/nextjs/src/server/getAuth.ts +++ b/packages/nextjs/src/server/getAuth.ts @@ -1,4 +1,4 @@ -import type { AuthObject, Organization, Session, SignedInAuthObject, SignedOutAuthObject, User } from '@clerk/backend'; +import type { Organization, Session, SignedInAuthObject, SignedOutAuthObject, User } from '@clerk/backend'; import { AuthStatus, constants, @@ -14,26 +14,11 @@ import type { SecretKeyOrApiKey } from '@clerk/types'; import { withLogger } from '../utils/debugLogger'; import { API_KEY, API_URL, API_VERSION, SECRET_KEY } from './clerkClient'; import { getAuthAuthHeaderMissing } from './errors'; -import type { RequestLike } from './types'; +import type { AuthObjectWithDeprecatedResources, RequestLike } from './types'; import { getAuthKeyFromRequest, getCookie, getHeader, injectSSRStateIntoObject } from './utils'; type GetAuthOpts = Partial; -type AuthObjectWithDeprecatedResources = Omit & { - /** - * @deprecated This will be removed in the next major version - */ - user: T['user']; - /** - * @deprecated This will be removed in the next major version - */ - organization: T['organization']; - /** - * @deprecated This will be removed in the next major version - */ - session: T['session']; -}; - export const createGetAuth = ({ debugLoggerName, noAuthStatusMessage, diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index 7834705209..dc2eb98126 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -1,4 +1,4 @@ -import type { OptionalVerifyTokenOptions } from '@clerk/backend'; +import type { AuthObject, OptionalVerifyTokenOptions } from '@clerk/backend'; import type { MultiDomainAndOrProxy, PublishableKeyOrFrontendApi, SecretKeyOrApiKey } from '@clerk/types'; import type { IncomingMessage } from 'http'; import type { NextApiRequest } from 'next'; @@ -20,3 +20,18 @@ export type WithAuthOptions = Partial & }; export type NextMiddlewareResult = Awaited>; + +export type AuthObjectWithDeprecatedResources = Omit & { + /** + * @deprecated This will be removed in the next major version + */ + user: T['user']; + /** + * @deprecated This will be removed in the next major version + */ + organization: T['organization']; + /** + * @deprecated This will be removed in the next major version + */ + session: T['session']; +}; diff --git a/packages/react/src/components/controlComponents.tsx b/packages/react/src/components/controlComponents.tsx index 983a647210..4b05c0cfd3 100644 --- a/packages/react/src/components/controlComponents.tsx +++ b/packages/react/src/components/controlComponents.tsx @@ -1,4 +1,9 @@ -import type { experimental__CheckAuthorizationWithoutPermission, HandleOAuthCallbackParams } from '@clerk/types'; +import type { + CheckAuthorizationWithCustomPermissions, + HandleOAuthCallbackParams, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, +} from '@clerk/types'; import React from 'react'; import { useAuthContext } from '../contexts/AuthContext'; @@ -41,23 +46,88 @@ export const ClerkLoading = ({ children }: React.PropsWithChildren): JS return <>{children}; }; -type GateProps = React.PropsWithChildren< - Parameters[0] & { +type ProtectProps = React.PropsWithChildren< + ( + | { + condition?: never; + role: OrganizationCustomRoleKey; + permission?: never; + } + | { + condition?: never; + role?: never; + permission: OrganizationCustomPermissionKey; + } + | { + condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; + role?: never; + permission?: never; + } + | { + condition?: never; + role?: never; + permission?: never; + } + ) & { fallback?: React.ReactNode; } >; /** - * @experimental The component is experimental and subject to change in future releases. + * Use `` in order to prevent unauthenticated or unauthorized users from accessing the children passed to the component. + * + * Examples: + * ``` + * + * + * has({permission:"a_permission_key"})} /> + * has({role:"a_role_key"})} /> + * Unauthorized

} /> + * ``` */ -export const experimental__Gate = ({ children, fallback, ...restAuthorizedParams }: GateProps) => { - const { experimental__has } = useAuth(); +export const Protect = ({ children, fallback, ...restAuthorizedParams }: ProtectProps) => { + const { isLoaded, has, userId } = useAuth(); - if (experimental__has(restAuthorizedParams)) { - return <>{children}; + /** + * Avoid flickering children or fallback while clerk is loading sessionId or userId + */ + if (!isLoaded) { + return null; + } + + /** + * Fallback to UI provided by user or `null` if authorization checks failed + */ + const unauthorized = <>{fallback ?? null}; + + const authorized = <>{children}; + + if (!userId) { + return unauthorized; + } + + /** + * Check against the results of `has` called inside the callback + */ + if (typeof restAuthorizedParams.condition === 'function') { + if (restAuthorizedParams.condition(has)) { + return authorized; + } + return unauthorized; + } + + if (restAuthorizedParams.role || restAuthorizedParams.permission) { + if (has(restAuthorizedParams)) { + return authorized; + } + return unauthorized; } - return <>{fallback ?? null}; + /** + * If neither of the authorization params are passed behave as the ``. + * If fallback is present render that instead of rendering nothing. + */ + return authorized; }; export const RedirectToSignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 55c0949dbb..439452bb52 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -14,7 +14,7 @@ export { ClerkLoading, SignedOut, SignedIn, - experimental__Gate as Experimental__Gate, + Protect, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, diff --git a/packages/react/src/contexts/AuthContext.ts b/packages/react/src/contexts/AuthContext.ts index ce346388e4..6be2af1680 100644 --- a/packages/react/src/contexts/AuthContext.ts +++ b/packages/react/src/contexts/AuthContext.ts @@ -1,5 +1,5 @@ import { createContextAndHook } from '@clerk/shared/react'; -import type { ActJWTClaim, MembershipRole } from '@clerk/types'; +import type { ActJWTClaim, MembershipRole, OrganizationCustomPermissionKey } from '@clerk/types'; export const [AuthContext, useAuthContext] = createContextAndHook<{ userId: string | null | undefined; @@ -8,4 +8,5 @@ export const [AuthContext, useAuthContext] = createContextAndHook<{ orgId: string | null | undefined; orgRole: MembershipRole | null | undefined; orgSlug: string | null | undefined; + orgPermissions: OrganizationCustomPermissionKey[] | null | undefined; }>('AuthContext'); diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 432444fc40..de4ca342f1 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -57,10 +57,11 @@ export function ClerkContextProvider(props: ClerkContextProvider): JSX.Element | organization, orgRole, orgSlug, + orgPermissions, } = derivedState; const authCtx = React.useMemo(() => { - const value = { sessionId, userId, actor, orgId, orgRole, orgSlug }; + const value = { sessionId, userId, actor, orgId, orgRole, orgSlug, orgPermissions }; return { value }; }, [sessionId, userId, actor, orgId, orgRole, orgSlug]); const userCtx = React.useMemo(() => ({ value: user }), [userId, user]); @@ -75,7 +76,7 @@ export function ClerkContextProvider(props: ClerkContextProvider): JSX.Element | }, [orgId, organization, lastOrganizationInvitation, lastOrganizationMember]); return ( - // @ts-expect-error + // @ts-expect-error value passed is of type IsomorphicClerk where the context expects LoadedClerk diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts index 521817ecd7..b285d0d468 100644 --- a/packages/react/src/errors.ts +++ b/packages/react/src/errors.ts @@ -50,3 +50,6 @@ export const customPageWrongProps = (componentName: string) => export const customLinkWrongProps = (componentName: string) => `Clerk: Missing props. <${componentName}.Link /> component requires the following props: url, label and labelIcon.`; + +export const useAuthHasRequiresRoleOrPermission = + 'Clerk: Missing parameters. `has` from `useAuth` requires a permission or role key to be passed. Example usage: `has({permission: "org:posts:edit"`'; diff --git a/packages/react/src/hooks/useAuth.ts b/packages/react/src/hooks/useAuth.ts index 7cfb14f788..e1d0f3105d 100644 --- a/packages/react/src/hooks/useAuth.ts +++ b/packages/react/src/hooks/useAuth.ts @@ -1,6 +1,6 @@ import type { ActJWTClaim, - experimental__CheckAuthorizationWithoutPermission, + CheckAuthorizationWithCustomPermissions, GetToken, MembershipRole, SignOut, @@ -9,13 +9,12 @@ import { useCallback } from 'react'; import { useAuthContext } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; -import { invalidStateError } from '../errors'; +import { invalidStateError, useAuthHasRequiresRoleOrPermission } from '../errors'; import type IsomorphicClerk from '../isomorphicClerk'; import { createGetToken, createSignOut } from './utils'; -type experimental__CheckAuthorizationSignedOut = ( - params?: Parameters[0], -) => false; +type CheckAuthorizationSignedOut = undefined; +type CheckAuthorizationWithoutOrgOrUser = (params?: Parameters[0]) => false; type UseAuthReturn = | { @@ -27,10 +26,7 @@ type UseAuthReturn = orgId: undefined; orgRole: undefined; orgSlug: undefined; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__has: experimental__CheckAuthorizationSignedOut; + has: CheckAuthorizationSignedOut; signOut: SignOut; getToken: GetToken; } @@ -43,10 +39,7 @@ type UseAuthReturn = orgId: null; orgRole: null; orgSlug: null; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__has: experimental__CheckAuthorizationSignedOut; + has: CheckAuthorizationWithoutOrgOrUser; signOut: SignOut; getToken: GetToken; } @@ -59,10 +52,7 @@ type UseAuthReturn = orgId: null; orgRole: null; orgSlug: null; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__has: experimental__CheckAuthorizationSignedOut; + has: CheckAuthorizationWithoutOrgOrUser; signOut: SignOut; getToken: GetToken; } @@ -75,10 +65,7 @@ type UseAuthReturn = orgId: string; orgRole: MembershipRole; orgSlug: string | null; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__has: experimental__CheckAuthorizationWithoutPermission; + has: CheckAuthorizationWithCustomPermissions; signOut: SignOut; getToken: GetToken; }; @@ -125,28 +112,33 @@ type UseAuth = () => UseAuthReturn; * } */ export const useAuth: UseAuth = () => { - const { sessionId, userId, actor, orgId, orgRole, orgSlug } = useAuthContext(); + const { sessionId, userId, actor, orgId, orgRole, orgSlug, orgPermissions } = useAuthContext(); const isomorphicClerk = useIsomorphicClerkContext() as unknown as IsomorphicClerk; const getToken: GetToken = useCallback(createGetToken(isomorphicClerk), [isomorphicClerk]); const signOut: SignOut = useCallback(createSignOut(isomorphicClerk), [isomorphicClerk]); const has = useCallback( - (params?: Parameters[0]) => { - if (!orgId || !userId || !orgRole) { - return false; + (params: Parameters[0]) => { + if (!params?.permission && !params?.role) { + throw new Error(useAuthHasRequiresRoleOrPermission); } - if (!params) { + if (!orgId || !userId || !orgRole || !orgPermissions) { return false; } + if (params.permission) { + return orgPermissions.includes(params.permission); + } + if (params.role) { return orgRole === params.role; } + return false; }, - [orgId, orgRole, userId], + [orgId, orgRole, userId, orgPermissions], ); if (sessionId === undefined && userId === undefined) { @@ -159,7 +151,7 @@ export const useAuth: UseAuth = () => { orgId: undefined, orgRole: undefined, orgSlug: undefined, - experimental__has: () => false, + has: undefined, signOut, getToken, }; @@ -175,7 +167,7 @@ export const useAuth: UseAuth = () => { orgId: null, orgRole: null, orgSlug: null, - experimental__has: () => false, + has: () => false, signOut, getToken, }; @@ -191,7 +183,7 @@ export const useAuth: UseAuth = () => { orgId, orgRole, orgSlug: orgSlug || null, - experimental__has: has, + has, signOut, getToken, }; @@ -207,7 +199,7 @@ export const useAuth: UseAuth = () => { orgId: null, orgRole: null, orgSlug: null, - experimental__has: () => false, + has: () => false, signOut, getToken, }; diff --git a/packages/react/src/utils/deriveState.ts b/packages/react/src/utils/deriveState.ts index 74770e84ac..6de63545c3 100644 --- a/packages/react/src/utils/deriveState.ts +++ b/packages/react/src/utils/deriveState.ts @@ -1,5 +1,12 @@ -import type { ActiveSessionResource, InitialState, OrganizationResource, Resources, UserResource } from '@clerk/types'; -import type { MembershipRole } from '@clerk/types'; +import type { + ActiveSessionResource, + InitialState, + MembershipRole, + OrganizationCustomPermissionKey, + OrganizationResource, + Resources, + UserResource, +} from '@clerk/types'; export const deriveState = (clerkLoaded: boolean, state: Resources, initialState: InitialState | undefined) => { if (!clerkLoaded && initialState) { @@ -16,6 +23,7 @@ const deriveFromSsrInitialState = (initialState: InitialState) => { const organization = initialState.organization as OrganizationResource; const orgId = initialState.orgId; const orgRole = initialState.orgRole as MembershipRole; + const orgPermissions = initialState.orgPermissions as OrganizationCustomPermissionKey[]; const orgSlug = initialState.orgSlug; const actor = initialState.actor; @@ -27,6 +35,7 @@ const deriveFromSsrInitialState = (initialState: InitialState) => { organization, orgId, orgRole, + orgPermissions, orgSlug, actor, lastOrganizationInvitation: null, diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index dbd0e5ce4b..fed415f346 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, OrganizationPermission } from './organizationMembership'; +import type { MembershipRole, OrganizationPermissionKey } from './organizationMembership'; import type { OrganizationSettingsJSON } from './organizationSettings'; import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { SamlIdpSlug } from './saml'; @@ -317,12 +317,7 @@ 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 & {}))[]; + permissions: OrganizationPermissionKey[]; public_metadata: OrganizationMembershipPublicMetadata; public_user_data: PublicUserDataJSON; role: MembershipRole; diff --git a/packages/types/src/jwtv2.ts b/packages/types/src/jwtv2.ts index 14151e8d55..ff0830a6fa 100644 --- a/packages/types/src/jwtv2.ts +++ b/packages/types/src/jwtv2.ts @@ -1,4 +1,4 @@ -import type { MembershipRole } from './organizationMembership'; +import type { MembershipRole, OrganizationCustomPermissionKey } from './organizationMembership'; export interface Jwt { header: JwtHeader; @@ -101,6 +101,11 @@ export interface JwtPayload extends CustomJwtSessionClaims { */ org_role?: MembershipRole; + /** + * Active organization role + */ + org_permissions?: OrganizationCustomPermissionKey[]; + /** * Any other JWT Claim Set member. */ diff --git a/packages/types/src/organizationMembership.ts b/packages/types/src/organizationMembership.ts index fdede93f9d..8ceb55e674 100644 --- a/packages/types/src/organizationMembership.ts +++ b/packages/types/src/organizationMembership.ts @@ -1,6 +1,17 @@ import type { OrganizationResource } from './organization'; import type { ClerkResource } from './resource'; import type { PublicUserData } from './session'; +import type { Autocomplete } from './utils'; + +interface Base { + permission: string; + role: string; +} + +declare global { + //eslint-disable-next-line @typescript-eslint/no-empty-interface + interface ClerkAuthorization {} +} declare global { /** @@ -25,12 +36,7 @@ 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 & {}))[]; + permissions: OrganizationPermissionKey[]; publicMetadata: OrganizationMembershipPublicMetadata; publicUserData: PublicUserData; role: MembershipRole; @@ -40,11 +46,30 @@ export interface OrganizationMembershipResource extends ClerkResource { update: (updateParams: UpdateOrganizationMembershipParams) => Promise; } -// Adding (string & {}) allows for getting eslint autocomplete but also accepts any string -// eslint-disable-next-line -export type MembershipRole = 'admin' | 'basic_member' | 'guest_member' | (string & {}); +export type OrganizationCustomPermissionKey = 'permission' extends keyof ClerkAuthorization + ? // @ts-expect-error Typescript cannot infer the existence of the `permission` key even if we checking it above + ClerkAuthorization['permission'] + : Base['permission']; + +export type OrganizationCustomRoleKey = 'role' extends keyof ClerkAuthorization + ? // @ts-expect-error Typescript cannot infer the existence of the `role` key even if we checking it above + ClerkAuthorization['role'] + : Base['role']; -export type OrganizationPermission = +/** + * @deprecated This type is deprecated and will be removed in the next major release. + * Use `OrganizationCustomRoleKey` instead. + * MembershipRole includes `admin`, `basic_member`, `guest_member`. With the introduction of "Custom roles" + * these types will no longer match a developer's custom logic. + */ +export type MembershipRole = 'role' extends keyof ClerkAuthorization + ? // @ts-expect-error Typescript cannot infer the existence of the `role` key even if we checking it above + // Disabling eslint rule because the error causes the type to become any when accessing a property that does not exist + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + ClerkAuthorization['role'] | 'admin' | 'basic_member' | 'guest_member' + : Autocomplete<'admin' | 'basic_member' | 'guest_member'>; + +export type OrganizationSystemPermissionKey = | 'org:sys_domains:manage' | 'org:sys_profile:manage' | 'org:sys_profile:delete' @@ -52,6 +77,17 @@ export type OrganizationPermission = | 'org:sys_memberships:manage' | 'org:sys_domains:read'; +/** + * OrganizationPermissionKey is a combination of system and custom permissions. + * System permissions are only accessible from FAPI and client-side operations/utils + */ +export type OrganizationPermissionKey = 'permission' extends keyof ClerkAuthorization + ? // @ts-expect-error Typescript cannot infer the existence of the `permission` key even if we checking it above + // Disabling eslint rule because the error causes the type to become any when accessing a property that does not exist + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + ClerkAuthorization['permission'] | OrganizationSystemPermissionKey + : Autocomplete; + export type UpdateOrganizationMembershipParams = { role: MembershipRole; }; diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index e6455d2489..477e204e2c 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -1,55 +1,39 @@ import type { ActJWTClaim } from './jwt'; -import type { OrganizationPermission } from './organizationMembership'; +import type { + MembershipRole, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, + OrganizationPermissionKey, +} from './organizationMembership'; import type { ClerkResource } from './resource'; import type { TokenResource } from './token'; import type { UserResource } from './user'; -export type experimental__CheckAuthorizationWithoutPermission = ( - isAuthorizedParams: CheckAuthorizationParamsWithoutPermission, -) => boolean; +export type CheckAuthorizationFn = (isAuthorizedParams: Params) => boolean; -type CheckAuthorizationParamsWithoutPermission = +export type CheckAuthorizationWithCustomPermissions = + CheckAuthorizationFn; + +type CheckAuthorizationParamsWithCustomPermissions = | { - some: { - role: string; - }[]; - role?: never; + role: OrganizationCustomRoleKey; + permission?: never; } | { - some?: never; - role: string; + role?: never; + permission: OrganizationCustomPermissionKey; }; -export type CheckAuthorization = (isAuthorizedParams: CheckAuthorizationParams) => boolean; +export type CheckAuthorization = CheckAuthorizationFn; type CheckAuthorizationParams = | { - some: ( - | { - role: string; - permission?: never; - } - | { - role?: never; - // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string - // eslint-disable-next-line - permission: OrganizationPermission | (string & {}); - } - )[]; - role?: never; - permission?: never; - } - | { - some?: never; - role: string; + role: MembershipRole; permission?: never; } | { - some?: never; role?: never; - // Adding (string & {}) allows for getting eslint autocomplete but also accepts any string - // eslint-disable-next-line - permission: OrganizationPermission | (string & {}); + permission: OrganizationPermissionKey; }; export interface SessionResource extends ClerkResource { @@ -67,10 +51,7 @@ export interface SessionResource extends ClerkResource { remove: () => Promise; touch: () => Promise; getToken: GetToken; - /** - * @experimental The method is experimental and subject to change in future releases. - */ - experimental__checkAuthorization: CheckAuthorization; + checkAuthorization: CheckAuthorization; clearCache: () => void; createdAt: Date; updatedAt: Date; diff --git a/packages/types/src/ssr.ts b/packages/types/src/ssr.ts index a77a28e2a4..6c970e2d86 100644 --- a/packages/types/src/ssr.ts +++ b/packages/types/src/ssr.ts @@ -1,7 +1,7 @@ import type { ActJWTClaim, ClerkJWTClaims } from './jwt'; import type { ActClaim, JwtPayload } from './jwtv2'; import type { OrganizationResource } from './organization'; -import type { MembershipRole } from './organizationMembership'; +import type { MembershipRole, OrganizationCustomPermissionKey } from './organizationMembership'; import type { SessionResource } from './session'; import type { UserResource } from './user'; import type { Serializable } from './utils'; @@ -30,5 +30,6 @@ export type InitialState = Serializable<{ orgId: string | undefined; orgRole: MembershipRole | undefined; orgSlug: string | undefined; + orgPermissions: OrganizationCustomPermissionKey[] | undefined; organization: OrganizationResource | undefined; }>; diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts index e3e28ea92b..f2e344ecf6 100644 --- a/packages/types/src/utils.ts +++ b/packages/types/src/utils.ts @@ -83,3 +83,9 @@ type IsSerializable = T extends Function ? false : true; export type Serializable = { [K in keyof T as IsSerializable extends true ? K : never]: T[K]; }; + +/** + * Enables autocompletion for a union type, while keeping the ability to use any string + * or type of `T` + */ +export type Autocomplete = U | (T & Record);