diff --git a/packages/astro/src/react/controlComponents.tsx b/packages/astro/src/react/controlComponents.tsx index 956a9f61347..5e9ac4ce889 100644 --- a/packages/astro/src/react/controlComponents.tsx +++ b/packages/astro/src/react/controlComponents.tsx @@ -1,13 +1,10 @@ -import type { HandleOAuthCallbackParams, PendingSessionOptions } from '@clerk/shared/types'; +import type { HandleOAuthCallbackParams, PendingSessionOptions, ProtectParams } from '@clerk/shared/types'; import { computed } from 'nanostores'; -import type { PropsWithChildren } from 'react'; -import React, { useEffect, useState } from 'react'; +import React, { type PropsWithChildren, useEffect, useState } from 'react'; import { $csrState } from '../stores/internal'; -import type { ProtectProps as _ProtectProps } from '../types'; import { useAuth } from './hooks'; -import type { WithClerkProp } from './utils'; -import { withClerk } from './utils'; +import { withClerk, type WithClerkProp } from './utils'; export function SignedOut({ children, treatPendingAsSignedOut }: PropsWithChildren) { const { userId } = useAuth({ treatPendingAsSignedOut }); @@ -70,7 +67,7 @@ export const ClerkLoading = ({ children }: React.PropsWithChildren): JSX.Element }; export type ProtectProps = React.PropsWithChildren< - _ProtectProps & { fallback?: React.ReactNode } & PendingSessionOptions + ProtectParams & { fallback?: React.ReactNode } & PendingSessionOptions >; /** @@ -140,7 +137,7 @@ export const Protect = ({ children, fallback, treatPendingAsSignedOut, ...restAu */ export const AuthenticateWithRedirectCallback = withClerk( ({ clerk, ...handleRedirectCallbackParams }: WithClerkProp) => { - React.useEffect(() => { + useEffect(() => { void clerk?.handleRedirectCallback(handleRedirectCallbackParams); }, []); diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index 120fb6d4a1c..01e780dea00 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -15,12 +15,12 @@ exports[`public exports > should not include a breaking change 1`] = ` "OrganizationProfile", "OrganizationSwitcher", "PricingTable", - "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", "RedirectToUserProfile", + "Show", "SignIn", "SignInButton", "SignInWithMetamaskButton", diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts index 2838dc6264b..f13e8e45c13 100644 --- a/packages/chrome-extension/src/react/re-exports.ts +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -10,12 +10,12 @@ export { OrganizationProfile, OrganizationSwitcher, PricingTable, - Protect, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, + Show, SignIn, SignInButton, SignInWithMetamaskButton, diff --git a/packages/expo/src/components/controlComponents.tsx b/packages/expo/src/components/controlComponents.tsx index bc42b9dbc73..33edad58240 100644 --- a/packages/expo/src/components/controlComponents.tsx +++ b/packages/expo/src/components/controlComponents.tsx @@ -1 +1 @@ -export { ClerkLoaded, ClerkLoading, SignedIn, SignedOut, Protect } from '@clerk/react'; +export { ClerkLoaded, ClerkLoading, Show, SignedIn, SignedOut } from '@clerk/react'; diff --git a/packages/nextjs/src/app-router/server/__tests__/controlComponents.test.tsx b/packages/nextjs/src/app-router/server/__tests__/controlComponents.test.tsx new file mode 100644 index 00000000000..680f8c96b1d --- /dev/null +++ b/packages/nextjs/src/app-router/server/__tests__/controlComponents.test.tsx @@ -0,0 +1,118 @@ +import type { ShowWhenCondition } from '@clerk/shared/types'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { auth } from '../auth'; +import { Show } from '../controlComponents'; + +vi.mock('../auth', () => ({ + auth: vi.fn(), +})); + +const mockAuth = auth as unknown as ReturnType; + +const render = async (element: Promise) => { + const resolved = await element; + if (!resolved) { + return ''; + } + return renderToStaticMarkup(resolved); +}; + +const setAuthReturn = (value: { has?: (params: unknown) => boolean; userId: string | null }) => { + mockAuth.mockResolvedValue(value); +}; + +const signedInWhen: ShowWhenCondition = 'signedIn'; +const signedOutWhen: ShowWhenCondition = 'signedOut'; + +describe('Show (App Router server)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders children when signed in', async () => { + const has = vi.fn(); + setAuthReturn({ has, userId: 'user_123' }); + + const html = await render( + Show({ + children:
signed-in
, + fallback:
fallback
, + treatPendingAsSignedOut: false, + when: signedInWhen, + }), + ); + + expect(mockAuth).toHaveBeenCalledWith({ treatPendingAsSignedOut: false }); + expect(html).toContain('signed-in'); + }); + + it('renders children when signed out', async () => { + const has = vi.fn(); + setAuthReturn({ has, userId: null }); + + const html = await render( + Show({ + children:
signed-out
, + fallback:
fallback
, + treatPendingAsSignedOut: false, + when: signedOutWhen, + }), + ); + + expect(html).toContain('signed-out'); + }); + + it('renders fallback when signed out but user is present', async () => { + const has = vi.fn(); + setAuthReturn({ has, userId: 'user_123' }); + + const html = await render( + Show({ + children:
signed-out
, + fallback:
fallback
, + treatPendingAsSignedOut: false, + when: signedOutWhen, + }), + ); + + expect(html).toContain('fallback'); + }); + + it('uses has() when when is an authorization object', async () => { + const has = vi.fn().mockReturnValue(true); + setAuthReturn({ has, userId: 'user_123' }); + + const html = await render( + Show({ + children:
authorized
, + fallback:
fallback
, + treatPendingAsSignedOut: false, + when: { role: 'admin' }, + }), + ); + + expect(has).toHaveBeenCalledWith({ role: 'admin' }); + expect(html).toContain('authorized'); + }); + + it('uses predicate when when is a function', async () => { + const has = vi.fn().mockReturnValue(true); + const predicate = vi.fn().mockReturnValue(true); + setAuthReturn({ has, userId: 'user_123' }); + + const html = await render( + Show({ + children:
predicate-pass
, + fallback:
fallback
, + treatPendingAsSignedOut: false, + when: predicate, + }), + ); + + expect(predicate).toHaveBeenCalledWith(has); + expect(html).toContain('predicate-pass'); + }); +}); diff --git a/packages/nextjs/src/app-router/server/controlComponents.tsx b/packages/nextjs/src/app-router/server/controlComponents.tsx index d640c63a055..3f50e6684f7 100644 --- a/packages/nextjs/src/app-router/server/controlComponents.tsx +++ b/packages/nextjs/src/app-router/server/controlComponents.tsx @@ -1,9 +1,21 @@ -import type { ProtectProps } from '@clerk/react'; -import type { PendingSessionOptions } from '@clerk/shared/types'; +import type { PendingSessionOptions, ProtectParams, ShowWhenCondition } from '@clerk/shared/types'; import React from 'react'; import { auth } from './auth'; +export type AppRouterProtectProps = React.PropsWithChildren< + ProtectParams & { + fallback?: React.ReactNode; + } & PendingSessionOptions +>; + +export type AppRouterShowProps = React.PropsWithChildren< + PendingSessionOptions & { + fallback?: React.ReactNode; + when: ShowWhenCondition; + } +>; + export async function SignedIn( props: React.PropsWithChildren, ): Promise { @@ -32,7 +44,7 @@ export async function SignedOut( * Unauthorized

} /> * ``` */ -export async function Protect(props: ProtectProps): Promise { +export async function Protect(props: AppRouterProtectProps): Promise { const { children, fallback, ...restAuthorizedParams } = props; const { has, userId } = await auth({ treatPendingAsSignedOut: props.treatPendingAsSignedOut }); @@ -69,3 +81,59 @@ export async function Protect(props: ProtectProps): Promise` to render children when an authorization or sign-in condition passes. + * + * @param props.when Condition that controls rendering. Accepts: + * - authorization objects such as `{ permission: "..." }`, `{ role: "..." }`, `{ feature: "..." }`, or `{ plan: "..." }` + * - the string `"signedIn"` to render when a user is present + * - the string `"signedOut"` to render when no user is present + * - predicate functions `(has) => boolean` that receive the `has` helper + * @param props.fallback Optional content rendered when the condition fails. + * @param props.children Content rendered when the condition passes. + * + * @example + * ```tsx + * Unauthorized

}> + * + *
+ * + * + * + * + * + * has({ permission: "org:read" }) && isFeatureEnabled}> + * + * + * + * + * + * + * ``` + */ +export async function Show(props: AppRouterShowProps): Promise { + const { children, fallback, treatPendingAsSignedOut, when } = props; + const { has, userId } = await auth({ treatPendingAsSignedOut }); + + const resolvedWhen = when; + const authorized = <>{children}; + const unauthorized = fallback ? <>{fallback} : null; + + if (typeof resolvedWhen === 'string') { + if (resolvedWhen === 'signedOut') { + return userId ? unauthorized : authorized; + } + return userId ? authorized : unauthorized; + } + + if (!userId) { + return unauthorized; + } + + if (typeof resolvedWhen === 'function') { + return resolvedWhen(has) ? authorized : unauthorized; + } + + return has(resolvedWhen) ? authorized : unauthorized; +} diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts index 1ab240a18f5..9006fbc594e 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -1,20 +1,20 @@ 'use client'; export { - ClerkLoaded, - ClerkLoading, + AuthenticateWithRedirectCallback, ClerkDegraded, ClerkFailed, - SignedOut, - SignedIn, - Protect, + ClerkLoaded, + ClerkLoading, + RedirectToCreateOrganization, + RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, - AuthenticateWithRedirectCallback, - RedirectToCreateOrganization, - RedirectToOrganizationProfile, + Show, + SignedIn, + SignedOut, } from '@clerk/react'; export { MultisessionAppSupport } from '@clerk/react/internal'; diff --git a/packages/nextjs/src/components.client.ts b/packages/nextjs/src/components.client.ts index aac3f82f65b..1d6fd04d0e6 100644 --- a/packages/nextjs/src/components.client.ts +++ b/packages/nextjs/src/components.client.ts @@ -1,2 +1,21 @@ export { ClerkProvider } from './client-boundary/ClerkProvider'; -export { SignedIn, SignedOut, Protect } from './client-boundary/controlComponents'; +export { Show, SignedIn, SignedOut } from './client-boundary/controlComponents'; + +/** + * `` is only available as a React Server Component in the App Router. + * For client-side conditional rendering, use `` instead. + * + * @example + * ```tsx + * // Server Component (App Router) + * ... + * + * // Client Component + * ... + * ``` + */ +export const Protect = () => { + throw new Error( + '`` is only available as a React Server Component. For client components, use `` instead.', + ); +}; diff --git a/packages/nextjs/src/components.server.ts b/packages/nextjs/src/components.server.ts index f73c8cc91c5..291aa1df659 100644 --- a/packages/nextjs/src/components.server.ts +++ b/packages/nextjs/src/components.server.ts @@ -1,11 +1,12 @@ import { ClerkProvider } from './app-router/server/ClerkProvider'; -import { Protect, SignedIn, SignedOut } from './app-router/server/controlComponents'; +import { Protect, Show, SignedIn, SignedOut } from './app-router/server/controlComponents'; -export { ClerkProvider, SignedOut, SignedIn, Protect }; +export { ClerkProvider, Protect, Show, SignedIn, SignedOut }; export type ServerComponentsServerModuleTypes = { ClerkProvider: typeof ClerkProvider; + Protect: typeof Protect; + Show: typeof Show; SignedIn: typeof SignedIn; SignedOut: typeof SignedOut; - Protect: typeof Protect; }; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index b9c24e9b7ce..98f1ffa9698 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -14,6 +14,7 @@ export { RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, + Show, } from './client-boundary/controlComponents'; /** @@ -73,6 +74,10 @@ import * as ComponentsModule from '#components'; import type { ServerComponentsServerModuleTypes } from './components.server'; export const ClerkProvider = ComponentsModule.ClerkProvider as ServerComponentsServerModuleTypes['ClerkProvider']; +/** + * Use `` in RSC (App Router) to restrict access based on authentication and authorization. + * For client components, use `` instead. + */ +export const Protect = ComponentsModule.Protect as ServerComponentsServerModuleTypes['Protect']; export const SignedIn = ComponentsModule.SignedIn as ServerComponentsServerModuleTypes['SignedIn']; export const SignedOut = ComponentsModule.SignedOut as ServerComponentsServerModuleTypes['SignedOut']; -export const Protect = ComponentsModule.Protect as ServerComponentsServerModuleTypes['Protect']; diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 54b196e9899..f3e3a74564e 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -29,13 +29,13 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "OrganizationProfile", "OrganizationSwitcher", "PricingTable", - "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", "RedirectToTasks", "RedirectToUserProfile", + "Show", "SignIn", "SignInButton", "SignInWithMetamaskButton", diff --git a/packages/react/src/components/controlComponents.tsx b/packages/react/src/components/controlComponents.tsx index bdeefbfa05a..046d75dece7 100644 --- a/packages/react/src/components/controlComponents.tsx +++ b/packages/react/src/components/controlComponents.tsx @@ -1,9 +1,5 @@ import { deprecated } from '@clerk/shared/deprecated'; -import type { - HandleOAuthCallbackParams, - PendingSessionOptions, - ProtectProps as _ProtectProps, -} from '@clerk/shared/types'; +import type { HandleOAuthCallbackParams, PendingSessionOptions, ShowWhenCondition } from '@clerk/shared/types'; import React from 'react'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; @@ -73,76 +69,74 @@ export const ClerkDegraded = ({ children }: React.PropsWithChildren) => return children; }; -export type ProtectProps = React.PropsWithChildren< - _ProtectProps & { +export type ShowProps = React.PropsWithChildren< + { fallback?: React.ReactNode; + when: ShowWhenCondition; } & PendingSessionOptions >; /** - * Use `` in order to prevent unauthenticated or unauthorized users from accessing the children passed to the component. + * Use `` to conditionally render content based on user authorization or sign-in state. * - * Examples: - * ``` - * - * - * has({permission:"a_permission_key"})} /> - * has({role:"a_role_key"})} /> - * Unauthorized

} /> + * @example + * ```tsx + * Unauthorized

}> + * + *
+ * + * + * + * + * + * has({ permission: "org:read" }) && isFeatureEnabled}> + * + * * ``` + * */ -export const Protect = ({ children, fallback, treatPendingAsSignedOut, ...restAuthorizedParams }: ProtectProps) => { - useAssertWrappedByClerkProvider('Protect'); +export const Show = ({ children, fallback, treatPendingAsSignedOut, when }: ShowProps) => { + useAssertWrappedByClerkProvider('Show'); - const { isLoaded, has, userId } = useAuth({ treatPendingAsSignedOut }); + const { has, isLoaded, userId } = useAuth({ treatPendingAsSignedOut }); - /** - * 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 resolvedWhen = when; + const authorized = children; const unauthorized = fallback ?? null; - const authorized = children; + if (resolvedWhen === 'signedOut') { + return userId ? unauthorized : authorized; + } 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 (resolvedWhen === 'signedIn') { + return authorized; } - if ( - restAuthorizedParams.role || - restAuthorizedParams.permission || - restAuthorizedParams.feature || - restAuthorizedParams.plan - ) { - if (has(restAuthorizedParams)) { - return authorized; - } - return unauthorized; + if (checkAuthorization(resolvedWhen, has)) { + return authorized; } - /** - * If neither of the authorization params are passed behave as the ``. - * If fallback is present render that instead of rendering nothing. - */ - return authorized; + return unauthorized; }; +function checkAuthorization( + when: Exclude, + has: NonNullable['has']>, +): boolean { + if (typeof when === 'function') { + return when(has); + } + return has(when); +} + export const RedirectToSignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { const { client, session } = clerk; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index dfbcedcfa93..247bb29ecd3 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -22,18 +22,18 @@ export { ClerkFailed, ClerkLoaded, ClerkLoading, - Protect, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, + Show, SignedIn, SignedOut, } from './controlComponents'; -export type { ProtectProps } from './controlComponents'; +export type { ShowProps } from './controlComponents'; export { SignInButton } from './SignInButton'; export { SignInWithMetamaskButton } from './SignInWithMetamaskButton'; diff --git a/packages/shared/src/types/protect.ts b/packages/shared/src/types/protect.ts index 0498c2b5f1b..4727dab46cb 100644 --- a/packages/shared/src/types/protect.ts +++ b/packages/shared/src/types/protect.ts @@ -1,11 +1,11 @@ import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; -import type { CheckAuthorizationWithCustomPermissions } from './session'; +import type { CheckAuthorizationWithCustomPermissions, PendingSessionOptions } from './session'; import type { Autocomplete } from './utils'; /** - * Props for the `` component, which restricts access to its children based on authentication and authorization. + * Authorization parameters used by `` and `auth.protect()`. * - * Use `ProtectProps` to specify the required Role, Permission, Feature, or Plan for access. + * Use `ProtectParams` to specify the required role, permission, feature, or plan for access. * * @example * ```tsx @@ -21,11 +21,11 @@ import type { Autocomplete } from './utils'; * // Require a specific Feature * * - * // Require a specific Plan + * // Require a specific plan * * ``` */ -export type ProtectProps = +export type ProtectParams = | { condition?: never; role: OrganizationCustomRoleKey; @@ -68,3 +68,46 @@ export type ProtectProps = feature?: never; plan?: never; }; + +/** + * @deprecated Use {@link ProtectParams} instead. + */ +export type ProtectProps = ProtectParams; + +/** + * Authorization condition for the `when` prop in ``. + * Can be an object specifying role, permission, feature, or plan, + * or a callback function receiving the `has` helper for complex conditions. + */ +export type ShowWhenCondition = + | 'signedIn' + | 'signedOut' + | ProtectParams + | ((has: CheckAuthorizationWithCustomPermissions) => boolean); + +/** + * Props for the `` component, which conditionally renders children based on authorization. + * + * @example + * ```tsx + * // Require a specific permission + * ... + * + * // Require a specific role + * ... + * + * // Use a custom condition callback + * has({ permission: "org:read" }) && someCondition}>... + * + * // Require a specific feature + * ... + * + * // Require a specific plan + * ... + * ``` + * + */ +export type ShowProps = PendingSessionOptions & { + fallback?: unknown; + when: ShowWhenCondition; +}; diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 3e1c592195b..42a6ab133df 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -34,13 +34,13 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "OrganizationProfile", "OrganizationSwitcher", "PricingTable", - "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", "RedirectToTasks", "RedirectToUserProfile", + "Show", "SignIn", "SignInButton", "SignInWithMetamaskButton", diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-protect-to-show.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-protect-to-show.fixtures.js new file mode 100644 index 00000000000..8a99c58f739 --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-protect-to-show.fixtures.js @@ -0,0 +1,338 @@ +export const fixtures = [ + { + name: 'Basic import transform', + source: ` +import { Protect } from "@clerk/react" + `, + output: ` +import { Show } from "@clerk/react" +`, + }, + { + name: 'Import transform with other imports', + source: ` +import { ClerkProvider, Protect, SignedIn } from "@clerk/react" + `, + output: ` +import { ClerkProvider, Show, SignedIn } from "@clerk/react" +`, + }, + { + name: 'Import from @clerk/nextjs without use client - should NOT transform (RSC)', + source: ` +import { Protect } from "@clerk/nextjs" + `, + output: null, + }, + { + name: 'Import transform for @clerk/chrome-extension', + source: ` +import { Protect } from "@clerk/chrome-extension" + `, + output: ` +import { Show } from "@clerk/chrome-extension" +`, + }, + { + name: 'Basic permission prop transform', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + + + + ); +} +`, + }, + { + name: 'Basic role prop transform', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + + + + ); +} +`, + }, + { + name: 'Feature prop transform', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + + + + ); +} +`, + }, + { + name: 'Plan prop transform', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + + + + ); +} +`, + }, + { + name: 'Condition prop transform', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + has({ permission: "org:read" })}> + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + has({ permission: "org:read" })}> + + + ); +} +`, + }, + { + name: 'With fallback prop', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + }> + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + }> + + + ); +} +`, + }, + { + name: 'Self-closing Protect', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + + ); +} +`, + }, + { + name: 'Handles directives', + source: `"use client"; + +import { Protect } from "@clerk/nextjs"; + +export function Protected() { + return ( + + + + ); +} +`, + output: `"use client"; + +import { Show } from "@clerk/nextjs"; + +export function Protected() { + return ( + + + + ); +}`, + }, + { + name: 'Dynamic permission value', + source: ` +import { Protect } from "@clerk/react" + +function App({ requiredPermission }) { + return ( + + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App({ requiredPermission }) { + return ( + + + + ); +} +`, + }, + { + name: 'RSC file (no use client) from @clerk/nextjs - should NOT transform', + source: `import { Protect } from "@clerk/nextjs"; + +export default async function Page() { + return ( + + + + ); +} +`, + output: null, + }, + { + name: 'Client file (use client) from @clerk/nextjs - should transform', + source: `"use client"; + +import { Protect } from "@clerk/nextjs"; + +export function ClientComponent() { + return ( + + + + ); +} +`, + output: `"use client"; + +import { Show } from "@clerk/nextjs"; + +export function ClientComponent() { + return ( + + + + ); +}`, + }, + { + name: 'Client-only package (@clerk/react) without use client - should still transform', + source: `import { Protect } from "@clerk/react"; + +function Component() { + return ( + + + + ); +} +`, + output: `import { Show } from "@clerk/react"; + +function Component() { + return ( + + + + ); +}`, + }, +]; diff --git a/packages/upgrade/src/codemods/__tests__/transform-protect-to-show.test.js b/packages/upgrade/src/codemods/__tests__/transform-protect-to-show.test.js new file mode 100644 index 00000000000..435c84b524d --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/transform-protect-to-show.test.js @@ -0,0 +1,18 @@ +import { applyTransform } from 'jscodeshift/dist/testUtils'; +import { describe, expect, it } from 'vitest'; + +import transformer from '../transform-protect-to-show.cjs'; +import { fixtures } from './__fixtures__/transform-protect-to-show.fixtures'; + +describe('transform-protect-to-show', () => { + it.each(fixtures)(`$name`, ({ source, output }) => { + const result = applyTransform(transformer, {}, { source }); + + if (output === null) { + // null output means no transformation should occur + expect(result).toBeFalsy(); + } else { + expect(result).toEqual(output.trim()); + } + }); +}); diff --git a/packages/upgrade/src/codemods/transform-protect-to-show.cjs b/packages/upgrade/src/codemods/transform-protect-to-show.cjs new file mode 100644 index 00000000000..c531aaee66b --- /dev/null +++ b/packages/upgrade/src/codemods/transform-protect-to-show.cjs @@ -0,0 +1,207 @@ +// Packages that are always client-side +const CLIENT_ONLY_PACKAGES = ['@clerk/chrome-extension', '@clerk/expo', '@clerk/react', '@clerk/vue']; +// Packages that can be used in both RSC and client components +const HYBRID_PACKAGES = ['@clerk/astro', '@clerk/nextjs']; + +/** + * Checks if a file has a 'use client' directive at the top. + */ +function hasUseClientDirective(root, j) { + const program = root.find(j.Program).get(); + const body = program.node.body; + + if (body.length === 0) { + return false; + } + + const firstStatement = body[0]; + + // Check for 'use client' as an expression statement with a string literal + if (j.ExpressionStatement.check(firstStatement)) { + const expression = firstStatement.expression; + if (j.Literal.check(expression) || j.StringLiteral.check(expression)) { + const value = expression.value; + return value === 'use client'; + } + // Handle DirectiveLiteral (used by some parsers like babel) + if (expression.type === 'DirectiveLiteral') { + return expression.value === 'use client'; + } + } + + // Also check directive field (some parsers use this) + if (firstStatement.directive === 'use client') { + return true; + } + + // Check for directives array in program node (babel parser) + const directives = program.node.directives; + if (directives && directives.length > 0) { + return directives.some(d => d.value && d.value.value === 'use client'); + } + + return false; +} + +/** + * Transforms `` component usage to `` component. + * + * Handles the following transformations: + * - `` → `` + * - `` → `` + * - `` → `` + * - `` → `` + * - ` ...}>` → ` ...}>` + * + * Also updates imports from `Protect` to `Show`. + * + * NOTE: For @clerk/nextjs, this only transforms files with 'use client' directive. + * RSC files using from @clerk/nextjs should NOT be transformed, + * as is still valid as an RSC-only component. + * + * @param {import('jscodeshift').FileInfo} fileInfo - The file information + * @param {import('jscodeshift').API} api - The API object provided by jscodeshift + * @returns {string|undefined} - The transformed source code if modifications were made + */ +module.exports = function transformProtectToShow({ source }, { jscodeshift: j }) { + const root = j(source); + let dirtyFlag = false; + const protectLocalNames = []; + + const isClientComponent = hasUseClientDirective(root, j); + + // Check if this file imports Protect from a hybrid package (like @clerk/nextjs) + // If so, and it's NOT a client component, skip the transformation + let hasHybridPackageImport = false; + HYBRID_PACKAGES.forEach(packageName => { + root.find(j.ImportDeclaration, { source: { value: packageName } }).forEach(path => { + const specifiers = path.node.specifiers || []; + if (specifiers.some(spec => j.ImportSpecifier.check(spec) && spec.imported.name === 'Protect')) { + hasHybridPackageImport = true; + } + }); + }); + + // Skip RSC files that import from hybrid packages + if (hasHybridPackageImport && !isClientComponent) { + return undefined; + } + + // Transform imports: Protect → Show + const allPackages = [...CLIENT_ONLY_PACKAGES, ...HYBRID_PACKAGES]; + allPackages.forEach(packageName => { + root.find(j.ImportDeclaration, { source: { value: packageName } }).forEach(path => { + const node = path.node; + const specifiers = node.specifiers || []; + + specifiers.forEach(spec => { + if (j.ImportSpecifier.check(spec) && spec.imported.name === 'Protect') { + const effectiveLocalName = spec.local ? spec.local.name : spec.imported.name; + spec.imported.name = 'Show'; + if (spec.local && spec.local.name === 'Protect') { + spec.local.name = 'Show'; + } + if (!protectLocalNames.includes(effectiveLocalName)) { + protectLocalNames.push(effectiveLocalName); + } + dirtyFlag = true; + } + }); + }); + }); + + // Transform JSX: + root.find(j.JSXElement).forEach(path => { + const openingElement = path.node.openingElement; + const closingElement = path.node.closingElement; + + // Check if this is a element + if (!j.JSXIdentifier.check(openingElement.name) || !protectLocalNames.includes(openingElement.name.name)) { + return; + } + + const originalName = openingElement.name.name; + + // Only rename if the component was used without an alias (as ). + // For aliased imports (e.g., Protect as MyProtect), keep the alias in place. + if (originalName === 'Protect') { + openingElement.name.name = 'Show'; + if (closingElement && j.JSXIdentifier.check(closingElement.name)) { + closingElement.name.name = 'Show'; + } + } + + const attributes = openingElement.attributes || []; + const authAttributes = []; + const otherAttributes = []; + let conditionAttr = null; + + // Separate auth-related attributes from other attributes + attributes.forEach(attr => { + if (!j.JSXAttribute.check(attr)) { + otherAttributes.push(attr); + return; + } + + const attrName = attr.name.name; + if (attrName === 'condition') { + conditionAttr = attr; + } else if (['feature', 'permission', 'plan', 'role'].includes(attrName)) { + authAttributes.push(attr); + } else { + otherAttributes.push(attr); + } + }); + + // Build the `when` prop + let whenValue = null; + + if (conditionAttr) { + // condition prop becomes the when callback directly + whenValue = conditionAttr.value; + } else if (authAttributes.length > 0) { + // Build an object from auth attributes + const properties = authAttributes.map(attr => { + const key = j.identifier(attr.name.name); + let value; + + if (j.JSXExpressionContainer.check(attr.value)) { + value = attr.value.expression; + } else if (j.StringLiteral.check(attr.value) || j.Literal.check(attr.value)) { + value = attr.value; + } else { + // Default string value + value = j.stringLiteral(attr.value?.value || ''); + } + + return j.objectProperty(key, value); + }); + + whenValue = j.jsxExpressionContainer(j.objectExpression(properties)); + } + + // Reconstruct attributes with `when` prop + const newAttributes = []; + + if (whenValue) { + newAttributes.push(j.jsxAttribute(j.jsxIdentifier('when'), whenValue)); + } + + // Add remaining attributes (fallback, etc.) + otherAttributes.forEach(attr => newAttributes.push(attr)); + + openingElement.attributes = newAttributes; + dirtyFlag = true; + }); + + if (!dirtyFlag) { + return undefined; + } + + let result = root.toSource(); + // Fix double semicolons that can occur when recast reprints directive prologues + result = result.replace(/^(['"`][^'"`]+['"`]);;/gm, '$1;'); + return result; +}; + +module.exports.parser = 'tsx'; diff --git a/packages/vue/src/components/controlComponents.ts b/packages/vue/src/components/controlComponents.ts index 5148700900f..eeb7dd546d6 100644 --- a/packages/vue/src/components/controlComponents.ts +++ b/packages/vue/src/components/controlComponents.ts @@ -2,7 +2,7 @@ import { deprecated } from '@clerk/shared/deprecated'; import type { HandleOAuthCallbackParams, PendingSessionOptions, - ProtectProps as _ProtectProps, + ProtectParams, RedirectOptions, } from '@clerk/shared/types'; import { defineComponent } from 'vue'; @@ -112,7 +112,7 @@ export const AuthenticateWithRedirectCallback = defineComponent((props: HandleOA return () => null; }); -export type ProtectProps = _ProtectProps & PendingSessionOptions; +export type ProtectProps = ProtectParams & PendingSessionOptions; export const Protect = defineComponent((props: ProtectProps, { slots }) => { const { isLoaded, has, userId } = useAuth({ treatPendingAsSignedOut: props.treatPendingAsSignedOut }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 232cb752a0f..13de6825908 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2355,7 +2355,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==}