diff --git a/.changeset/migrate-to-show.md b/.changeset/migrate-to-show.md new file mode 100644 index 00000000000..ddf86e9fbd0 --- /dev/null +++ b/.changeset/migrate-to-show.md @@ -0,0 +1,5 @@ +--- +'@clerk/upgrade': minor +--- + +Add a `transform-protect-to-show` codemod that migrates ``, ``, `` usages to `` with automatic prop and import updates. diff --git a/.changeset/show-the-guards.md b/.changeset/show-the-guards.md new file mode 100644 index 00000000000..76f30d82828 --- /dev/null +++ b/.changeset/show-the-guards.md @@ -0,0 +1,11 @@ +--- +'@clerk/astro': major +'@clerk/chrome-extension': major +'@clerk/expo': major +'@clerk/nextjs': major +'@clerk/react': major +'@clerk/shared': minor +'@clerk/vue': major +--- + +Introduce `` as the cross-framework authorization control component and remove ``, ``, and `` in favor of ``, updating shared types and framework wrappers to align with the new API. diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index a1ebb09ec63..b85cc6926ce 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -81,26 +81,6 @@ const withEmailCodesQuickstart = withEmailCodes .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '') .setEnvVariable('public', 'CLERK_SIGN_UP_URL', ''); -const withAPCore1ClerkV4 = environmentConfig() - .setId('withAPCore1ClerkV4') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk); - -// Uses staging instance which runs Core 3 -const withAPCore3ClerkV4 = environmentConfig() - .setId('withAPCore3ClerkV4') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_API_URL', 'https://api.clerkstage.dev') - .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-billing-staging').sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-billing-staging').pk); - -const withAPCore1ClerkV6 = environmentConfig() - .setId('withAPCore1ClerkV6') - .setEnvVariable('public', 'CLERK_TELEMETRY_DISABLED', true) - .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-email-codes').sk) - .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-email-codes').pk); - // Uses staging instance which runs Core 3 const withAPCore3ClerkV6 = environmentConfig() .setId('withAPCore3ClerkV6') @@ -213,9 +193,6 @@ export const envs = { base, sessionsProd1, withAPIKeys, - withAPCore1ClerkV4, - withAPCore1ClerkV6, - withAPCore3ClerkV4, withAPCore3ClerkLatest, withAPCore3ClerkV6, withBilling, diff --git a/integration/presets/next.ts b/integration/presets/next.ts index 4bae080f834..dd68d2068d9 100644 --- a/integration/presets/next.ts +++ b/integration/presets/next.ts @@ -26,25 +26,12 @@ const appRouterQuickstart = appRouter const appRouterAPWithClerkNextLatest = appRouterQuickstart.clone().setName('next-app-router-ap-clerk-next-latest'); -const appRouterAPWithClerkNextV4 = appRouterQuickstart +const appRouterQuickstartV6 = appRouter .clone() - .setName('next-app-router-ap-clerk-next-v4') - .addDependency('@clerk/nextjs', '4') - .addFile( - 'src/middleware.ts', - () => `import { authMiddleware } from '@clerk/nextjs'; + .setName('next-app-router-quickstart-v6') + .useTemplate(templates['next-app-router-quickstart-v6']); - export default authMiddleware({ - publicRoutes: ['/'] - }); - - export const config = { - matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'], - }; - `, - ); - -const appRouterAPWithClerkNextV6 = appRouterQuickstart +const appRouterAPWithClerkNextV6 = appRouterQuickstartV6 .clone() .setName('next-app-router-ap-clerk-next-v6') .addDependency('@clerk/nextjs', '6'); @@ -54,6 +41,6 @@ export const next = { appRouterTurbo, appRouterQuickstart, appRouterAPWithClerkNextLatest, - appRouterAPWithClerkNextV4, appRouterAPWithClerkNextV6, + appRouterQuickstartV6, } as const; diff --git a/integration/scripts/logger.ts b/integration/scripts/logger.ts index 8b690573bb2..b27c60a845b 100644 --- a/integration/scripts/logger.ts +++ b/integration/scripts/logger.ts @@ -34,6 +34,11 @@ export const createLogger = (opts: CreateLoggerOptions) => { console.info(`${chalk[prefixColor](`[${prefix}]`)} ${msg}`); } }, + warn: (msg: string, error?: unknown) => { + const errorMsg = error instanceof Error ? error.message : String(error ?? ''); + const fullMsg = errorMsg ? `${msg} ${errorMsg}` : msg; + console.warn(`${chalk.yellow(`[${prefix}]`)} ${fullMsg}`); + }, child: (childOpts: CreateLoggerOptions) => { return createLogger({ prefix: `${prefix} :: ${childOpts.prefix}`, color: prefixColor }); }, diff --git a/integration/templates/astro-hybrid/src/pages/index.astro b/integration/templates/astro-hybrid/src/pages/index.astro index 47168af011b..88ab11cf71c 100644 --- a/integration/templates/astro-hybrid/src/pages/index.astro +++ b/integration/templates/astro-hybrid/src/pages/index.astro @@ -1,5 +1,5 @@ --- -import { UserButton, SignInButton, SignedIn, SignedOut } from '@clerk/astro/components'; +import { Show, UserButton, SignInButton } from '@clerk/astro/components'; import { OrganizationSwitcher } from '@clerk/astro/react'; import Layout from '../layouts/Layout.astro'; @@ -7,16 +7,16 @@ export const prerender = true; --- - +

Signed out

-
- +
+

Signed in

-
+
diff --git a/integration/templates/astro-hybrid/src/pages/only-admins.astro b/integration/templates/astro-hybrid/src/pages/only-admins.astro index 9a786b993a0..a185fa480cb 100644 --- a/integration/templates/astro-hybrid/src/pages/only-admins.astro +++ b/integration/templates/astro-hybrid/src/pages/only-admins.astro @@ -1,13 +1,13 @@ --- -import { Protect } from '@clerk/astro/components'; +import { Show } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; export const prerender = true; --- - +

I'm an admin

Not an admin

-
+
diff --git a/integration/templates/astro-hybrid/src/pages/only-members.astro b/integration/templates/astro-hybrid/src/pages/only-members.astro index cf6f6b05e48..ed7608bb8d9 100644 --- a/integration/templates/astro-hybrid/src/pages/only-members.astro +++ b/integration/templates/astro-hybrid/src/pages/only-members.astro @@ -1,16 +1,16 @@ --- -import { Protect } from '@clerk/astro/components'; +import { Show } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; export const prerender = false; --- -

I'm a member

Not a member

-
+
diff --git a/integration/templates/astro-hybrid/src/pages/ssr.astro b/integration/templates/astro-hybrid/src/pages/ssr.astro index 0db930a6145..0c0611e626f 100644 --- a/integration/templates/astro-hybrid/src/pages/ssr.astro +++ b/integration/templates/astro-hybrid/src/pages/ssr.astro @@ -1,5 +1,5 @@ --- -import { UserButton, SignInButton, SignedIn, SignedOut } from '@clerk/astro/components'; +import { Show, UserButton, SignInButton } from '@clerk/astro/components'; import { OrganizationSwitcher } from '@clerk/astro/react'; import Layout from '../layouts/Layout.astro'; @@ -7,16 +7,16 @@ export const prerender = false; --- - +

Signed out

-
- + +

Signed in

-
+
diff --git a/integration/templates/astro-node/src/layouts/Layout.astro b/integration/templates/astro-node/src/layouts/Layout.astro index 3e168321da2..17639bb1214 100644 --- a/integration/templates/astro-node/src/layouts/Layout.astro +++ b/integration/templates/astro-node/src/layouts/Layout.astro @@ -5,7 +5,7 @@ interface Props { const { title } = Astro.props; -import { SignedIn, SignedOut } from '@clerk/astro/components'; +import { Show } from '@clerk/astro/components'; import { LanguagePicker } from '../components/LanguagePicker'; import CustomUserButton from '../components/CustomUserButton.astro'; --- @@ -80,11 +80,11 @@ import CustomUserButton from '../components/CustomUserButton.astro';
- + - + - +
-
+ diff --git a/integration/templates/astro-node/src/layouts/react/Layout.astro b/integration/templates/astro-node/src/layouts/react/Layout.astro index 41b878880e3..4a5fc2be65c 100644 --- a/integration/templates/astro-node/src/layouts/react/Layout.astro +++ b/integration/templates/astro-node/src/layouts/react/Layout.astro @@ -5,7 +5,7 @@ interface Props { const { title } = Astro.props; -import { SignedIn, SignedOut, UserButton } from '@clerk/astro/react'; +import { Show, UserButton } from '@clerk/astro/react'; import { LanguagePicker } from '../../components/LanguagePicker'; --- @@ -79,11 +79,11 @@ import { LanguagePicker } from '../../components/LanguagePicker'; - + diff --git a/integration/templates/astro-node/src/pages/billing/checkout-btn.astro b/integration/templates/astro-node/src/pages/billing/checkout-btn.astro index 736992e6033..3ae0fbfa9db 100644 --- a/integration/templates/astro-node/src/pages/billing/checkout-btn.astro +++ b/integration/templates/astro-node/src/pages/billing/checkout-btn.astro @@ -1,17 +1,17 @@ --- -import { SignedIn, __experimental_CheckoutButton as CheckoutButton } from '@clerk/astro/components'; +import { Show, __experimental_CheckoutButton as CheckoutButton } from '@clerk/astro/components'; import Layout from '../../layouts/Layout.astro'; ---
- + Checkout Now - +
diff --git a/integration/templates/astro-node/src/pages/index.astro b/integration/templates/astro-node/src/pages/index.astro index 089eac14653..c7a92f9330c 100644 --- a/integration/templates/astro-node/src/pages/index.astro +++ b/integration/templates/astro-node/src/pages/index.astro @@ -2,12 +2,12 @@ import Layout from '../layouts/Layout.astro'; import Card from '../components/Card.astro'; -import { SignedIn, SignedOut, SignOutButton, OrganizationSwitcher } from '@clerk/astro/components'; +import { Show, SignOutButton, OrganizationSwitcher } from '@clerk/astro/components'; ---

Welcome to Astro

- + Sign out! - +
@@ -26,7 +26,7 @@ import { SignedIn, SignedOut, SignOutButton, OrganizationSwitcher } from '@clerk role='list' class='link-card-grid' > - + - - + + - +
diff --git a/integration/templates/astro-node/src/pages/only-admins.astro b/integration/templates/astro-node/src/pages/only-admins.astro index f2241732454..8fcb3f86062 100644 --- a/integration/templates/astro-node/src/pages/only-admins.astro +++ b/integration/templates/astro-node/src/pages/only-admins.astro @@ -1,11 +1,12 @@ --- -import { Protect } from '@clerk/astro/components'; +import { Show } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; ---
diff --git a/integration/templates/astro-node/src/pages/only-members.astro b/integration/templates/astro-node/src/pages/only-members.astro index f013bd27cdb..99b7a640b0b 100644 --- a/integration/templates/astro-node/src/pages/only-members.astro +++ b/integration/templates/astro-node/src/pages/only-members.astro @@ -1,11 +1,12 @@ --- -import { Protect } from '@clerk/astro/components'; +import { Show } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; ---
- + +

I'm a member

Not a member

Go to Admin Page
-

I'm a member

-
+
diff --git a/integration/templates/astro-node/src/pages/pricing-table.astro b/integration/templates/astro-node/src/pages/pricing-table.astro index 85539e1158f..2e6bbfc6d09 100644 --- a/integration/templates/astro-node/src/pages/pricing-table.astro +++ b/integration/templates/astro-node/src/pages/pricing-table.astro @@ -1,5 +1,5 @@ --- -import { Protect, PricingTable } from '@clerk/astro/components'; +import { Show, PricingTable } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; const newSubscriptionRedirectUrl = Astro.url.searchParams.get('newSubscriptionRedirectUrl'); @@ -7,15 +7,15 @@ const newSubscriptionRedirectUrl = Astro.url.searchParams.get('newSubscriptionRe
- +

user in free

-
- + +

user in pro

-
- + +

user in plus

-
+
diff --git a/integration/templates/astro-node/src/pages/react/index.astro b/integration/templates/astro-node/src/pages/react/index.astro index 5fe777167f7..11271836228 100644 --- a/integration/templates/astro-node/src/pages/react/index.astro +++ b/integration/templates/astro-node/src/pages/react/index.astro @@ -2,12 +2,12 @@ import Layout from '../../layouts/react/Layout.astro'; import Card from '../../components/Card.astro'; -import { SignedIn, SignedOut, SignOutButton, OrganizationSwitcher } from '@clerk/astro/react'; +import { Show, SignOutButton, OrganizationSwitcher } from '@clerk/astro/react'; ---

Welcome to Astro + React

- + Sign out! - +
@@ -31,7 +31,7 @@ import { SignedIn, SignedOut, SignOutButton, OrganizationSwitcher } from '@clerk role='list' class='link-card-grid' > - + - - + + - +
diff --git a/integration/templates/astro-node/src/pages/react/only-admins.astro b/integration/templates/astro-node/src/pages/react/only-admins.astro index 0ad2bc1b2ba..bc3b46e75d8 100644 --- a/integration/templates/astro-node/src/pages/react/only-admins.astro +++ b/integration/templates/astro-node/src/pages/react/only-admins.astro @@ -1,23 +1,28 @@ --- -import { Protect } from '@clerk/astro/react'; +import { Show } from '@clerk/astro/react'; import Layout from '../../layouts/react/Layout.astro'; ---
- - -

Not an admin

- Go to Members Page -

I'm an admin

-
+ + + !has({ role: 'org:admin' })} + > +

Not an admin

+ + Go to Members Page + +
diff --git a/integration/templates/astro-node/src/pages/react/only-members.astro b/integration/templates/astro-node/src/pages/react/only-members.astro index e0fd91dc11f..f8efcb9ff2f 100644 --- a/integration/templates/astro-node/src/pages/react/only-members.astro +++ b/integration/templates/astro-node/src/pages/react/only-members.astro @@ -1,14 +1,12 @@ --- -import { Protect } from '@clerk/astro/react'; +import { Show } from '@clerk/astro/components'; import Layout from '../../layouts/react/Layout.astro'; ---
- + +

I'm a member

Not a member

Go to Admin Page
-

I'm a member

-
+
diff --git a/integration/templates/astro-node/src/pages/server-islands.astro b/integration/templates/astro-node/src/pages/server-islands.astro index 47f43bb3aef..c22d33595cf 100644 --- a/integration/templates/astro-node/src/pages/server-islands.astro +++ b/integration/templates/astro-node/src/pages/server-islands.astro @@ -1,16 +1,16 @@ --- -import { Protect } from '@clerk/astro/components'; +import { Show } from '@clerk/astro/components'; import Layout from '../layouts/Layout.astro'; ---
-

Loading

- +

Not an admin

I'm an admin

-
+
diff --git a/integration/templates/astro-node/src/pages/transitions/index.astro b/integration/templates/astro-node/src/pages/transitions/index.astro index af29b083fcc..4985e2b77e3 100644 --- a/integration/templates/astro-node/src/pages/transitions/index.astro +++ b/integration/templates/astro-node/src/pages/transitions/index.astro @@ -1,15 +1,15 @@ --- -import { SignedIn, SignedOut, UserButton } from '@clerk/astro/components'; +import { Show, UserButton } from '@clerk/astro/components'; import Layout from '../../layouts/ViewTransitionsLayout.astro'; ---
- + Sign in - - + + - +
diff --git a/integration/templates/expo-web/app/index.tsx b/integration/templates/expo-web/app/index.tsx index 431bf8c209f..ee296309576 100644 --- a/integration/templates/expo-web/app/index.tsx +++ b/integration/templates/expo-web/app/index.tsx @@ -1,6 +1,6 @@ -import { Text, View } from 'react-native'; -import { SignedIn, SignedOut } from '@clerk/expo'; +import { Show } from '@clerk/expo'; import { UserButton } from '@clerk/expo/web'; +import { Text, View } from 'react-native'; export default function Index() { return ( @@ -11,13 +11,13 @@ export default function Index() { alignItems: 'center', }} > - + You are signed in! - - + + You are signed out - + ); } diff --git a/integration/templates/index.ts b/integration/templates/index.ts index eb97913a159..14875609189 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -6,6 +6,7 @@ export const templates = { // 'next-app-router': fileURLToPath(new URL('./next-app-router', import.meta.url)), 'next-app-router': resolve(__dirname, './next-app-router'), 'next-app-router-quickstart': resolve(__dirname, './next-app-router-quickstart'), + 'next-app-router-quickstart-v6': resolve(__dirname, './next-app-router-quickstart-v6'), 'react-cra': resolve(__dirname, './react-cra'), 'react-vite': resolve(__dirname, './react-vite'), 'express-vite': resolve(__dirname, './express-vite'), diff --git a/integration/templates/next-app-router-quickstart-v6/.gitignore b/integration/templates/next-app-router-quickstart-v6/.gitignore new file mode 100644 index 00000000000..8f322f0d8f4 --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/integration/templates/next-app-router-quickstart-v6/README.md b/integration/templates/next-app-router-quickstart-v6/README.md new file mode 100644 index 00000000000..f4da3c4c1cf --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/integration/templates/next-app-router-quickstart-v6/next.config.js b/integration/templates/next-app-router-quickstart-v6/next.config.js new file mode 100644 index 00000000000..954fac0d40b --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, +}; + +module.exports = nextConfig; diff --git a/integration/templates/next-app-router-quickstart-v6/package.json b/integration/templates/next-app-router-quickstart-v6/package.json new file mode 100644 index 00000000000..355009e1e5c --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/package.json @@ -0,0 +1,23 @@ +{ + "name": "next-app-router-quickstart-v6", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build", + "dev": "next dev", + "lint": "next lint", + "start": "next start" + }, + "dependencies": { + "@types/node": "^20.12.12", + "@types/react": "18.3.12", + "@types/react-dom": "18.3.1", + "next": "^15.0.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "typescript": "^5.7.3" + }, + "engines": { + "node": ">=20.9.0" + } +} diff --git a/integration/templates/next-app-router-quickstart-v6/public/next.svg b/integration/templates/next-app-router-quickstart-v6/public/next.svg new file mode 100644 index 00000000000..5174b28c565 --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integration/templates/next-app-router-quickstart-v6/public/vercel.svg b/integration/templates/next-app-router-quickstart-v6/public/vercel.svg new file mode 100644 index 00000000000..d2f84222734 --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/integration/templates/next-app-router-quickstart-v6/src/app/favicon.ico b/integration/templates/next-app-router-quickstart-v6/src/app/favicon.ico new file mode 100644 index 00000000000..718d6fea483 Binary files /dev/null and b/integration/templates/next-app-router-quickstart-v6/src/app/favicon.ico differ diff --git a/integration/templates/next-app-router-quickstart-v6/src/app/globals.css b/integration/templates/next-app-router-quickstart-v6/src/app/globals.css new file mode 100644 index 00000000000..760b257c8cc --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/src/app/globals.css @@ -0,0 +1,78 @@ +:root { + --max-width: 1100px; + --border-radius: 12px; + --font-mono: + ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', + 'Source Code Pro', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; + + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; + + --primary-glow: conic-gradient( + from 180deg at 50% 50%, + #16abff33 0deg, + #0885ff33 55deg, + #54d6ff33 120deg, + #0071ff33 160deg, + transparent 360deg + ); + --secondary-glow: radial-gradient(rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + + --tile-start-rgb: 239, 245, 249; + --tile-end-rgb: 228, 232, 233; + --tile-border: conic-gradient(#00000080, #00000040, #00000030, #00000020, #00000010, #00000010, #00000080); + + --callout-rgb: 238, 240, 241; + --callout-border-rgb: 172, 175, 176; + --card-rgb: 180, 185, 188; + --card-border-rgb: 131, 134, 135; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + + --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); + --secondary-glow: linear-gradient(to bottom right, rgba(1, 65, 255, 0), rgba(1, 65, 255, 0), rgba(1, 65, 255, 0.3)); + + --tile-start-rgb: 2, 13, 46; + --tile-end-rgb: 2, 5, 19; + --tile-border: conic-gradient(#ffffff80, #ffffff40, #ffffff30, #ffffff20, #ffffff10, #ffffff10, #ffffff80); + + --callout-rgb: 20, 20, 20; + --callout-border-rgb: 108, 108, 108; + --card-rgb: 100, 100, 100; + --card-border-rgb: 200, 200, 200; + } +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient(to bottom, transparent, rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb)); +} + +a { + color: inherit; + text-decoration: none; +} + +@media (prefers-color-scheme: dark) { + html { + color-scheme: dark; + } +} diff --git a/integration/templates/next-app-router-quickstart-v6/src/app/layout.tsx b/integration/templates/next-app-router-quickstart-v6/src/app/layout.tsx new file mode 100644 index 00000000000..411ba883c93 --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/src/app/layout.tsx @@ -0,0 +1,26 @@ +import './globals.css'; +import { Inter } from 'next/font/google'; +import { ClerkProvider } from '@clerk/nextjs'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/integration/templates/next-app-router-quickstart-v6/src/app/page.module.css b/integration/templates/next-app-router-quickstart-v6/src/app/page.module.css new file mode 100644 index 00000000000..14b1649f654 --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/src/app/page.module.css @@ -0,0 +1,223 @@ +.main { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 6rem; + min-height: 100vh; +} + +.description { + display: inherit; + justify-content: inherit; + align-items: inherit; + font-size: 0.85rem; + max-width: var(--max-width); + width: 100%; + z-index: 2; + font-family: var(--font-mono); +} + +.description a { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; +} + +.description p { + position: relative; + margin: 0; + padding: 1rem; + background-color: rgba(var(--callout-rgb), 0.5); + border: 1px solid rgba(var(--callout-border-rgb), 0.3); + border-radius: var(--border-radius); +} + +.code { + font-weight: 700; + font-family: var(--font-mono); +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(25%, auto)); + width: var(--max-width); + max-width: 100%; +} + +.card { + padding: 1rem 1.2rem; + border-radius: var(--border-radius); + background: rgba(var(--card-rgb), 0); + border: 1px solid rgba(var(--card-border-rgb), 0); + transition: + background 200ms, + border 200ms; +} + +.card span { + display: inline-block; + transition: transform 200ms; +} + +.card h2 { + font-weight: 600; + margin-bottom: 0.7rem; +} + +.card p { + margin: 0; + opacity: 0.6; + font-size: 0.9rem; + line-height: 1.5; + max-width: 30ch; +} + +.center { + display: flex; + justify-content: center; + align-items: center; + position: relative; + padding: 4rem 0; +} + +.center::before { + background: var(--secondary-glow); + border-radius: 50%; + width: 480px; + height: 360px; + margin-left: -400px; +} + +.center::after { + background: var(--primary-glow); + width: 240px; + height: 180px; + z-index: -1; +} + +.center::before, +.center::after { + content: ''; + left: 50%; + position: absolute; + filter: blur(45px); + transform: translateZ(0); +} + +.logo { + position: relative; +} +/* Enable hover only on non-touch devices */ +@media (hover: hover) and (pointer: fine) { + .card:hover { + background: rgba(var(--card-rgb), 0.1); + border: 1px solid rgba(var(--card-border-rgb), 0.15); + } + + .card:hover span { + transform: translateX(4px); + } +} + +@media (prefers-reduced-motion) { + .card:hover span { + transform: none; + } +} + +/* Mobile */ +@media (max-width: 700px) { + .content { + padding: 4rem; + } + + .grid { + grid-template-columns: 1fr; + margin-bottom: 120px; + max-width: 320px; + text-align: center; + } + + .card { + padding: 1rem 2.5rem; + } + + .card h2 { + margin-bottom: 0.5rem; + } + + .center { + padding: 8rem 0 6rem; + } + + .center::before { + transform: none; + height: 300px; + } + + .description { + font-size: 0.8rem; + } + + .description a { + padding: 1rem; + } + + .description p, + .description div { + display: flex; + justify-content: center; + position: fixed; + width: 100%; + } + + .description p { + align-items: center; + inset: 0 0 auto; + padding: 2rem 1rem 1.4rem; + border-radius: 0; + border: none; + border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); + background: linear-gradient(to bottom, rgba(var(--background-start-rgb), 1), rgba(var(--callout-rgb), 0.5)); + background-clip: padding-box; + backdrop-filter: blur(24px); + } + + .description div { + align-items: flex-end; + pointer-events: none; + inset: auto 0 0; + padding: 2rem; + height: 200px; + background: linear-gradient(to bottom, transparent 0%, rgb(var(--background-end-rgb)) 40%); + z-index: 1; + } +} + +/* Tablet and Smaller Desktop */ +@media (min-width: 701px) and (max-width: 1120px) { + .grid { + grid-template-columns: repeat(2, 50%); + } +} + +@media (prefers-color-scheme: dark) { + .vercelLogo { + filter: invert(1); + } + + .logo { + filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); + } +} + +@keyframes rotate { + from { + transform: rotate(360deg); + } + to { + transform: rotate(0deg); + } +} diff --git a/integration/templates/next-app-router-quickstart-v6/src/app/page.tsx b/integration/templates/next-app-router-quickstart-v6/src/app/page.tsx new file mode 100644 index 00000000000..7e15c54f93e --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/src/app/page.tsx @@ -0,0 +1,17 @@ +import { SignInButton, SignUpButton, SignedIn, SignedOut, UserButton } from '@clerk/nextjs'; + +export default function Home() { + return ( +
+ +

signed-out-state

+ + +
+ +

signed-in-state

+ +
+
+ ); +} diff --git a/integration/templates/next-app-router-quickstart-v6/src/middleware.ts b/integration/templates/next-app-router-quickstart-v6/src/middleware.ts new file mode 100644 index 00000000000..71c73d054cb --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/src/middleware.ts @@ -0,0 +1,7 @@ +import { clerkMiddleware } from '@clerk/nextjs/server'; + +export default clerkMiddleware(); + +export const config = { + matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'], +}; diff --git a/integration/templates/next-app-router-quickstart-v6/tsconfig.json b/integration/templates/next-app-router-quickstart-v6/tsconfig.json new file mode 100644 index 00000000000..683a38afc1d --- /dev/null +++ b/integration/templates/next-app-router-quickstart-v6/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"], + "@clerk/nextjs": ["../../../packages/nextjs/src/index.ts"], + "@clerk/nextjs/*": ["../../../packages/nextjs/src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/integration/templates/next-app-router-quickstart/src/app/page.tsx b/integration/templates/next-app-router-quickstart/src/app/page.tsx index 98ee4d4bcd3..797aceb64a1 100644 --- a/integration/templates/next-app-router-quickstart/src/app/page.tsx +++ b/integration/templates/next-app-router-quickstart/src/app/page.tsx @@ -1,17 +1,17 @@ -import { SignedIn, SignedOut, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'; +import { Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'; export default function Home() { return (
- +

signed-out-state

-
- + +

signed-in-state

-
+
); } diff --git a/integration/templates/next-app-router-quickstart/tsconfig.json b/integration/templates/next-app-router-quickstart/tsconfig.json index 0c7555fa765..683a38afc1d 100644 --- a/integration/templates/next-app-router-quickstart/tsconfig.json +++ b/integration/templates/next-app-router-quickstart/tsconfig.json @@ -9,7 +9,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", @@ -20,7 +20,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@clerk/nextjs": ["../../../packages/nextjs/src/index.ts"], + "@clerk/nextjs/*": ["../../../packages/nextjs/src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/integration/templates/next-app-router/src/app/billing/checkout-btn/page.tsx b/integration/templates/next-app-router/src/app/billing/checkout-btn/page.tsx index 4904d056e95..2ba15a81a67 100644 --- a/integration/templates/next-app-router/src/app/billing/checkout-btn/page.tsx +++ b/integration/templates/next-app-router/src/app/billing/checkout-btn/page.tsx @@ -1,17 +1,17 @@ -import { SignedIn } from '@clerk/nextjs'; +import { Show } from '@clerk/nextjs'; import { CheckoutButton } from '@clerk/nextjs/experimental'; export default function Home() { return (
- + Checkout Now - +
); } diff --git a/integration/templates/next-app-router/src/app/page.tsx b/integration/templates/next-app-router/src/app/page.tsx index 86ba722b3f3..241053ed048 100644 --- a/integration/templates/next-app-router/src/app/page.tsx +++ b/integration/templates/next-app-router/src/app/page.tsx @@ -1,4 +1,4 @@ -import { Protect, SignedIn, SignedOut, SignIn, UserButton } from '@clerk/nextjs'; +import { Show, SignIn, UserButton } from '@clerk/nextjs'; import Link from 'next/link'; import { ClientId } from './client-id'; @@ -7,18 +7,23 @@ export default function Home() {
Loading user button} /> - SignedIn - SignedOut - SignedIn from protect - + SignedIn + SignedOut + + SignedIn from protect + +

user in free

-
- + +

user in pro

-
- + +

user in plus

-
+ - +

user in free

-
- + +

user in pro

-
- + +

user in plus

-
+ ); diff --git a/integration/templates/next-app-router/src/app/settings/rcc-protect/page.tsx b/integration/templates/next-app-router/src/app/settings/rcc-protect/page.tsx index 5b371ed9b2f..bd13e14387d 100644 --- a/integration/templates/next-app-router/src/app/settings/rcc-protect/page.tsx +++ b/integration/templates/next-app-router/src/app/settings/rcc-protect/page.tsx @@ -1,14 +1,13 @@ 'use client'; -import { Protect } from '@clerk/nextjs'; +import { Show } from '@clerk/nextjs'; export default function Page() { return ( - User is missing permissions

} + when={{ permission: 'org:posts:manage' }} >

User has access

-
+ ); } diff --git a/integration/templates/next-app-router/src/app/settings/rsc-protect/page.tsx b/integration/templates/next-app-router/src/app/settings/rsc-protect/page.tsx index 9e21b23d034..56871f6d926 100644 --- a/integration/templates/next-app-router/src/app/settings/rsc-protect/page.tsx +++ b/integration/templates/next-app-router/src/app/settings/rsc-protect/page.tsx @@ -1,12 +1,12 @@ -import { Protect } from '@clerk/nextjs'; +import { Show } from '@clerk/nextjs'; export default function Page() { return ( - User is not admin

} + when={{ role: 'org:admin' }} >

User has access

-
+ ); } diff --git a/integration/templates/react-cra/src/App.tsx b/integration/templates/react-cra/src/App.tsx index 38197953f08..28309fe6b6f 100644 --- a/integration/templates/react-cra/src/App.tsx +++ b/integration/templates/react-cra/src/App.tsx @@ -1,15 +1,15 @@ // @ts-ignore import React from 'react'; import './App.css'; -import { SignedIn, SignedOut, SignIn, UserButton } from '@clerk/react'; +import { Show, SignIn, UserButton } from '@clerk/react'; function App() { return (
- + - - Signed In + + Signed In
); diff --git a/integration/templates/react-router-library/src/App.tsx b/integration/templates/react-router-library/src/App.tsx index 93dfdf04385..259bb2fc944 100644 --- a/integration/templates/react-router-library/src/App.tsx +++ b/integration/templates/react-router-library/src/App.tsx @@ -1,15 +1,15 @@ -import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/react-router'; +import { Show, SignInButton, UserButton } from '@clerk/react-router'; import './App.css'; function App() { return (
- + - - + + - +
); } diff --git a/integration/templates/react-router-node/app/routes/home.tsx b/integration/templates/react-router-node/app/routes/home.tsx index 57161c90b48..9adefddec39 100644 --- a/integration/templates/react-router-node/app/routes/home.tsx +++ b/integration/templates/react-router-node/app/routes/home.tsx @@ -1,4 +1,4 @@ -import { SignedIn, SignedOut, UserButton } from '@clerk/react-router'; +import { Show, UserButton } from '@clerk/react-router'; import type { Route } from './+types/home'; export function meta({}: Route.MetaArgs) { @@ -9,8 +9,8 @@ export default function Home() { return (
- SignedIn - SignedOut + SignedIn + SignedOut
); } diff --git a/integration/templates/react-vite/src/App.tsx b/integration/templates/react-vite/src/App.tsx index 3c7aabd5906..a826457118f 100644 --- a/integration/templates/react-vite/src/App.tsx +++ b/integration/templates/react-vite/src/App.tsx @@ -1,4 +1,4 @@ -import { OrganizationSwitcher, SignedIn, SignedOut, UserButton } from '@clerk/react'; +import { OrganizationSwitcher, Show, UserButton } from '@clerk/react'; import { Link } from 'react-router-dom'; import React from 'react'; import { ClientId } from './client-id'; @@ -9,8 +9,8 @@ function App() { Loading organization switcher} /> - SignedOut - SignedIn + SignedOut + SignedIn Protected
); diff --git a/integration/templates/react-vite/src/protected/index.tsx b/integration/templates/react-vite/src/protected/index.tsx index 2eb58aa8d76..1a8bcccaac5 100644 --- a/integration/templates/react-vite/src/protected/index.tsx +++ b/integration/templates/react-vite/src/protected/index.tsx @@ -1,11 +1,11 @@ -import { SignedIn } from '@clerk/react'; +import { Show } from '@clerk/react'; export default function Page() { return (
- +
Protected
-
+
); } diff --git a/integration/templates/tanstack-react-start/src/routes/index.tsx b/integration/templates/tanstack-react-start/src/routes/index.tsx index a5c9bfe8dd4..7564211722a 100644 --- a/integration/templates/tanstack-react-start/src/routes/index.tsx +++ b/integration/templates/tanstack-react-start/src/routes/index.tsx @@ -1,4 +1,4 @@ -import { SignedIn, UserButton, SignOutButton, SignedOut, SignIn } from '@clerk/tanstack-react-start'; +import { Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/')({ @@ -9,7 +9,7 @@ function Home() { return (

Index Route

- +

You are signed in!

View your profile here

@@ -18,12 +18,12 @@ function Home() {
- - + +

You are signed out

-
+
); } diff --git a/integration/templates/vue-vite/src/App.vue b/integration/templates/vue-vite/src/App.vue index 6477a90213f..c0c615dd2ec 100644 --- a/integration/templates/vue-vite/src/App.vue +++ b/integration/templates/vue-vite/src/App.vue @@ -1,5 +1,5 @@ @@ -11,12 +11,12 @@ import LanguagePicker from './components/LanguagePicker.vue';

Vue Clerk Integration test

- + - - + + Sign in - +
diff --git a/integration/templates/vue-vite/src/views/Admin.vue b/integration/templates/vue-vite/src/views/Admin.vue index cda8c50afb7..1a685a48e50 100644 --- a/integration/templates/vue-vite/src/views/Admin.vue +++ b/integration/templates/vue-vite/src/views/Admin.vue @@ -1,12 +1,12 @@ diff --git a/integration/templates/vue-vite/src/views/Home.vue b/integration/templates/vue-vite/src/views/Home.vue index e12e3680290..e89dbf87707 100644 --- a/integration/templates/vue-vite/src/views/Home.vue +++ b/integration/templates/vue-vite/src/views/Home.vue @@ -1,16 +1,18 @@ diff --git a/integration/templates/vue-vite/src/views/billing/CheckoutBtn.vue b/integration/templates/vue-vite/src/views/billing/CheckoutBtn.vue index 39c23365733..70c7dbd545e 100644 --- a/integration/templates/vue-vite/src/views/billing/CheckoutBtn.vue +++ b/integration/templates/vue-vite/src/views/billing/CheckoutBtn.vue @@ -1,17 +1,17 @@ diff --git a/integration/tests/astro/components.test.ts b/integration/tests/astro/components.test.ts index 93e8f21b35b..e05722b722f 100644 --- a/integration/tests/astro/components.test.ts +++ b/integration/tests/astro/components.test.ts @@ -406,11 +406,13 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f test('react/ render content based on Clerk loaded status', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/utility'); - await expect(u.page.getByText('Clerk is loading')).toBeVisible(); - await expect(u.page.getByText('Clerk is loaded')).toBeHidden(); + const clerkIsLoaded = u.page.getByText('Clerk is loaded'); + const clerkIsLoading = u.page.getByText('Clerk is loading'); + + // Depending on cache/timing, Clerk may already be loaded by the time the page is ready. + await expect(clerkIsLoading.or(clerkIsLoaded)).toBeVisible(); await u.page.waitForClerkJsLoaded(); - await expect(u.page.getByText('Clerk is loaded')).toBeVisible(); - await expect(u.page.getByText('Clerk is loading')).toBeHidden(); + await expect(clerkIsLoaded).toBeVisible(); }); // ----- redirect @@ -482,7 +484,7 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f await u.po.userButton.waitForMounted(); }); - test('server islands protect component shows correct states', async ({ page, context }) => { + test('server islands Show component shows correct states', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/server-islands'); @@ -509,6 +511,6 @@ testAgainstRunningApps({ withPattern: ['astro.node.withCustomRoles'] })('basic f // This is being investigated upstream with the Astro team. The test is commented out for now // to unblock development and will be revisited once the root cause is resolved. // await expect(u.page.getByText('Loading')).toBeHidden(); - await expect(u.page.getByText("I'm an admin")).toBeVisible(); + await expect(u.page.getByText("I'm an admin")).toBeVisible({ timeout: 15_000 }); }); }); diff --git a/integration/tests/astro/hybrid.test.ts b/integration/tests/astro/hybrid.test.ts index a0ff4c92fb3..cc909d058df 100644 --- a/integration/tests/astro/hybrid.test.ts +++ b/integration/tests/astro/hybrid.test.ts @@ -94,7 +94,7 @@ testAgainstRunningApps({ withPattern: ['astro.static.withCustomRoles'] })( await expect(u.page.getByText("I'm an admin")).toBeVisible(); }); - test('render Protect fallback', async ({ page, context }) => { + test('render Show fallback', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToAppHome(); diff --git a/integration/tests/next-account-portal/clerk-v6-ap-core-3.test.ts b/integration/tests/next-account-portal/clerk-ap-core-3-v6.test.ts similarity index 100% rename from integration/tests/next-account-portal/clerk-v6-ap-core-3.test.ts rename to integration/tests/next-account-portal/clerk-ap-core-3-v6.test.ts diff --git a/integration/tests/next-account-portal/clerk-v7-ap-core-3.test.ts b/integration/tests/next-account-portal/clerk-ap-core-3-v7.test.ts similarity index 100% rename from integration/tests/next-account-portal/clerk-v7-ap-core-3.test.ts rename to integration/tests/next-account-portal/clerk-ap-core-3-v7.test.ts diff --git a/integration/tests/next-account-portal/clerk-v4-ap-core-1.test.ts b/integration/tests/next-account-portal/clerk-v4-ap-core-1.test.ts deleted file mode 100644 index 984f846ebf7..00000000000 --- a/integration/tests/next-account-portal/clerk-v4-ap-core-1.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { test } from '@playwright/test'; - -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import type { FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; -import { testSignIn, testSignUp, testSSR } from './common'; - -test.describe('Next with ClerkJS V4 <-> Account Portal Core 1 @ap-flows', () => { - test.describe.configure({ mode: 'serial' }); - let app: Application; - let fakeUser: FakeUser; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - app = await appConfigs.next.appRouterAPWithClerkNextV4.clone().commit(); - await app.setup(); - await app.withEnv(appConfigs.envs.withAPCore1ClerkV4); - await app.dev(); - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - }); - - test.afterAll(async () => { - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('sign in', async ({ page, context }) => { - await testSignIn({ app, page, context, fakeUser }); - }); - - test('sign up', async ({ page, context }) => { - await testSignUp({ app, page, context, fakeUser }); - }); - - test('ssr', async ({ page, context }) => { - await testSSR({ app, page, context, fakeUser }); - }); -}); diff --git a/integration/tests/next-account-portal/clerk-v4-ap-core-3.test.ts b/integration/tests/next-account-portal/clerk-v4-ap-core-3.test.ts deleted file mode 100644 index 5d4cb8ee01b..00000000000 --- a/integration/tests/next-account-portal/clerk-v4-ap-core-3.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { test } from '@playwright/test'; - -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import type { FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; -import { testSignIn, testSignUp, testSSR } from './common'; - -test.describe('Next with ClerkJS V4 <-> Account Portal Core 3 @ap-flows', () => { - test.describe.configure({ mode: 'serial' }); - let app: Application; - let fakeUser: FakeUser; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - app = await appConfigs.next.appRouterAPWithClerkNextV4.clone().commit(); - await app.setup(); - await app.withEnv(appConfigs.envs.withAPCore3ClerkV4); - await app.dev(); - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - }); - - test.afterAll(async () => { - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('sign in', async ({ page, context }) => { - await testSignIn({ app, page, context, fakeUser }); - }); - - test('sign up', async ({ page, context }) => { - await testSignUp({ app, page, context, fakeUser }); - }); - - test('ssr', async ({ page, context }) => { - await testSSR({ app, page, context, fakeUser }); - }); -}); diff --git a/integration/tests/next-account-portal/clerk-v6-ap-core-1.test.ts b/integration/tests/next-account-portal/clerk-v6-ap-core-1.test.ts deleted file mode 100644 index 1c0b935c510..00000000000 --- a/integration/tests/next-account-portal/clerk-v6-ap-core-1.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { test } from '@playwright/test'; - -import type { Application } from '../../models/application'; -import { appConfigs } from '../../presets'; -import type { FakeUser } from '../../testUtils'; -import { createTestUtils } from '../../testUtils'; -import { testSignIn, testSignUp, testSSR } from './common'; - -test.describe('Next with ClerkJS V6 <-> Account Portal Core 1 @ap-flows', () => { - test.describe.configure({ mode: 'serial' }); - let app: Application; - let fakeUser: FakeUser; - - test.beforeAll(async () => { - test.setTimeout(90_000); // Wait for app to be ready - app = await appConfigs.next.appRouterAPWithClerkNextV6.clone().commit(); - await app.setup(); - await app.withEnv(appConfigs.envs.withAPCore1ClerkV6); - await app.dev(); - const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); - }); - - test.afterAll(async () => { - await fakeUser.deleteIfExists(); - await app.teardown(); - }); - - test('sign in', async ({ page, context }) => { - await testSignIn({ app, page, context, fakeUser }); - }); - - test('sign up', async ({ page, context }) => { - await testSignUp({ app, page, context, fakeUser }); - }); - - test('ssr', async ({ page, context }) => { - await testSSR({ app, page, context, fakeUser }); - }); -}); diff --git a/integration/tests/vue/components.test.ts b/integration/tests/vue/components.test.ts index c803a6adc6b..c5aa518a358 100644 --- a/integration/tests/vue/components.test.ts +++ b/integration/tests/vue/components.test.ts @@ -259,7 +259,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te await u.po.signIn.waitForMounted(); }); - test('renders component contents to admins', async ({ page, context }) => { + test('renders guard contents to admins', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/sign-in'); await u.po.signIn.waitForMounted(); diff --git a/packages/astro/src/astro-components/control/Protect.astro b/packages/astro/src/astro-components/control/Protect.astro deleted file mode 100644 index c7e039987f4..00000000000 --- a/packages/astro/src/astro-components/control/Protect.astro +++ /dev/null @@ -1,52 +0,0 @@ ---- -import ProtectCSR from './ProtectCSR.astro'; -import ProtectSSR from './ProtectSSR.astro'; - -import { isStaticOutput } from 'virtual:@clerk/astro/config'; -import type { ProtectProps } from '../../types'; - -type Props = ProtectProps & { - isStatic?: boolean; - /** - * The class name to apply to the outermost element of the component. - * This class is only applied to static components. - */ - class?: string; - /** - * The class name to apply to the wrapper element of the default slot. - * This class is only applied to static components. - */ - defaultSlotWrapperClass?: string; - /** - * The class name to apply to the wrapper element of the fallback slot. - * This class is only applied to static components. - */ - fallbackSlotWrapperClass?: string; -}; - -const { isStatic, ...props } = Astro.props; - -const ProtectComponent = isStaticOutput(isStatic) ? ProtectCSR : ProtectSSR; - -// Note: Astro server islands also use a "fallback" slot for loading states -// See: https://docs.astro.build/en/guides/server-islands/#server-island-fallback-content -// We use "protect-fallback" as our preferred slot name to avoid conflicts -const hasProtectFallback = Astro.slots.has('protect-fallback'); ---- - - - - { - hasProtectFallback ? ( - - ) : ( - - ) - } - diff --git a/packages/astro/src/astro-components/control/ProtectCSR.astro b/packages/astro/src/astro-components/control/ProtectCSR.astro deleted file mode 100644 index e3aa5ca8f3c..00000000000 --- a/packages/astro/src/astro-components/control/ProtectCSR.astro +++ /dev/null @@ -1,79 +0,0 @@ ---- -import type { ProtectProps } from '../../types'; - -type Props = Omit & { - class?: string; - defaultSlotWrapperClass?: string; - fallbackSlotWrapperClass?: string; -}; - -const { - role, - permission, - feature, - plan, - class: className, - defaultSlotWrapperClass, - fallbackSlotWrapperClass, -} = Astro.props; ---- - - - - - - - diff --git a/packages/astro/src/astro-components/control/ProtectSSR.astro b/packages/astro/src/astro-components/control/ProtectSSR.astro deleted file mode 100644 index e894af3ee03..00000000000 --- a/packages/astro/src/astro-components/control/ProtectSSR.astro +++ /dev/null @@ -1,15 +0,0 @@ ---- -import type { ProtectProps } from '../../types'; - -type Props = ProtectProps; - -const { has, userId } = Astro.locals.auth(); -const isUnauthorized = - !userId || - (typeof Astro.props.condition === 'function' && !Astro.props.condition(has)) || - ((Astro.props.role || Astro.props.permission || Astro.props.feature || Astro.props.plan) && !has(Astro.props)); - -const hasProtectFallback = Astro.slots.has('protect-fallback'); ---- - -{isUnauthorized ? hasProtectFallback ? : : } diff --git a/packages/astro/src/astro-components/control/Show.astro b/packages/astro/src/astro-components/control/Show.astro new file mode 100644 index 00000000000..9a72534ddff --- /dev/null +++ b/packages/astro/src/astro-components/control/Show.astro @@ -0,0 +1,48 @@ +--- +import ShowCSR from './ShowCSR.astro'; +import ShowSSR from './ShowSSR.astro'; + +import { isStaticOutput } from 'virtual:@clerk/astro/config'; +import type { ShowProps } from '../../types'; + +type Props = ShowProps & { + isStatic?: boolean; + /** + * The class name to apply to the outermost element of the component. + * This class is only applied to static components. + */ + class?: string; +}; + +const { isStatic, when, ...rest } = Astro.props; + +if (typeof when === 'undefined') { + throw new Error('@clerk/astro: requires a `when` prop.'); +} + +const props = { ...rest, when }; + +const ShowComponent = isStaticOutput(isStatic) ? ShowCSR : ShowSSR; + +// Note: Astro server islands also use a "fallback" slot for loading states +// See: https://docs.astro.build/en/guides/server-islands/#server-island-fallback-content +// We use "show-fallback" as our preferred slot name to avoid conflicts +const hasShowFallback = Astro.slots.has('show-fallback'); +--- + + + + { + hasShowFallback ? ( + + ) : ( + + ) + } + diff --git a/packages/astro/src/astro-components/control/ShowCSR.astro b/packages/astro/src/astro-components/control/ShowCSR.astro new file mode 100644 index 00000000000..b58ae386d86 --- /dev/null +++ b/packages/astro/src/astro-components/control/ShowCSR.astro @@ -0,0 +1,90 @@ +--- +import type { ShowProps } from '../../types'; + +type Props = Omit & { + class?: string; +}; + +const { when, class: className } = Astro.props; + +// For CSR, we need to serialize the when prop +// String values ('signedIn', 'signedOut') are used as-is +// Object values are serialized as data attributes +const isStringWhen = typeof when === 'string'; +const whenCondition = isStringWhen ? when : null; +const role = !isStringWhen && typeof when === 'object' ? when.role : undefined; +const permission = !isStringWhen && typeof when === 'object' ? when.permission : undefined; +const feature = !isStringWhen && typeof when === 'object' ? when.feature : undefined; +const plan = !isStringWhen && typeof when === 'object' ? when.plan : undefined; +--- + + + + + + + diff --git a/packages/astro/src/astro-components/control/ShowSSR.astro b/packages/astro/src/astro-components/control/ShowSSR.astro new file mode 100644 index 00000000000..1150160d735 --- /dev/null +++ b/packages/astro/src/astro-components/control/ShowSSR.astro @@ -0,0 +1,30 @@ +--- +import type { ShowProps } from '../../types'; + +type Props = ShowProps; + +const { has, userId } = Astro.locals.auth(); +const { when } = Astro.props; + +const showContent = (() => { + // String conditions + if (when === 'signedIn') return !!userId; + if (when === 'signedOut') return !userId; + + // Function condition + if (typeof when === 'function') return !!userId && when(has); + + // Object-based conditions (role, permission, feature, plan) + if (typeof when === 'object' && when !== null) { + if (!userId) return false; + return has(when); + } + + // Default: show if signed in + return !!userId; +})(); + +const hasShowFallback = Astro.slots.has('show-fallback'); +--- + +{showContent ? : hasShowFallback ? : } diff --git a/packages/astro/src/astro-components/control/SignedIn.astro b/packages/astro/src/astro-components/control/SignedIn.astro deleted file mode 100644 index 5b1b484e13d..00000000000 --- a/packages/astro/src/astro-components/control/SignedIn.astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -import SignedInCSR from './SignedInCSR.astro'; -import SignedInSSR from './SignedInSSR.astro'; - -import { isStaticOutput } from 'virtual:@clerk/astro/config'; - -type Props = { - isStatic?: boolean; - /** - * The class name to apply to the outermost element of the component. - * This class is only applied to static components. - */ - class?: string; -}; - -const { isStatic, class: className } = Astro.props; - -const SignedInComponent = isStaticOutput(isStatic) ? SignedInCSR : SignedInSSR; ---- - - - - diff --git a/packages/astro/src/astro-components/control/SignedInCSR.astro b/packages/astro/src/astro-components/control/SignedInCSR.astro deleted file mode 100644 index 750c60f718e..00000000000 --- a/packages/astro/src/astro-components/control/SignedInCSR.astro +++ /dev/null @@ -1,30 +0,0 @@ ---- -type Props = { - class?: string; -}; - -const { class: className } = Astro.props; ---- - - - - diff --git a/packages/astro/src/astro-components/control/SignedInSSR.astro b/packages/astro/src/astro-components/control/SignedInSSR.astro deleted file mode 100644 index 446b1997116..00000000000 --- a/packages/astro/src/astro-components/control/SignedInSSR.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -const { userId } = Astro.locals.auth(); ---- - -{userId ? : null} diff --git a/packages/astro/src/astro-components/control/SignedOut.astro b/packages/astro/src/astro-components/control/SignedOut.astro deleted file mode 100644 index 9161a518d3b..00000000000 --- a/packages/astro/src/astro-components/control/SignedOut.astro +++ /dev/null @@ -1,23 +0,0 @@ ---- -import SignedOutCSR from './SignedOutCSR.astro'; -import SignedOutSSR from './SignedOutSSR.astro'; - -import { isStaticOutput } from 'virtual:@clerk/astro/config'; - -type Props = { - isStatic?: boolean; - /** - * The class name to apply to the outermost element of the component. - * This class is only applied to static components. - */ - class?: string; -}; - -const { isStatic, class: className } = Astro.props; - -const SignedOutComponent = isStaticOutput(isStatic) ? SignedOutCSR : SignedOutSSR; ---- - - - - diff --git a/packages/astro/src/astro-components/control/SignedOutCSR.astro b/packages/astro/src/astro-components/control/SignedOutCSR.astro deleted file mode 100644 index 3417917ac94..00000000000 --- a/packages/astro/src/astro-components/control/SignedOutCSR.astro +++ /dev/null @@ -1,30 +0,0 @@ ---- -type Props = { - class?: string; -}; - -const { class: className } = Astro.props; ---- - - - - diff --git a/packages/astro/src/astro-components/control/SignedOutSSR.astro b/packages/astro/src/astro-components/control/SignedOutSSR.astro deleted file mode 100644 index df4e890b890..00000000000 --- a/packages/astro/src/astro-components/control/SignedOutSSR.astro +++ /dev/null @@ -1,5 +0,0 @@ ---- -const { userId } = Astro.locals.auth(); ---- - -{!userId ? : null} diff --git a/packages/astro/src/astro-components/index.ts b/packages/astro/src/astro-components/index.ts index 5c9d9b8361f..f4472c143f9 100644 --- a/packages/astro/src/astro-components/index.ts +++ b/packages/astro/src/astro-components/index.ts @@ -1,9 +1,7 @@ /** * Control Components */ -export { default as SignedIn } from './control/SignedIn.astro'; -export { default as SignedOut } from './control/SignedOut.astro'; -export { default as Protect } from './control/Protect.astro'; +export { default as Show } from './control/Show.astro'; export { default as AuthenticateWithRedirectCallback } from './control/AuthenticateWithRedirectCallback.astro'; /** diff --git a/packages/astro/src/react/controlComponents.tsx b/packages/astro/src/react/controlComponents.tsx index 956a9f61347..4b345a18fd8 100644 --- a/packages/astro/src/react/controlComponents.tsx +++ b/packages/astro/src/react/controlComponents.tsx @@ -1,30 +1,10 @@ -import type { HandleOAuthCallbackParams, PendingSessionOptions } from '@clerk/shared/types'; +import type { HandleOAuthCallbackParams, PendingSessionOptions, ShowWhenCondition } from '@clerk/shared/types'; import { computed } from 'nanostores'; -import type { PropsWithChildren } from 'react'; import React, { 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'; - -export function SignedOut({ children, treatPendingAsSignedOut }: PropsWithChildren) { - const { userId } = useAuth({ treatPendingAsSignedOut }); - - if (userId) { - return null; - } - return children; -} - -export function SignedIn({ children, treatPendingAsSignedOut }: PropsWithChildren) { - const { userId } = useAuth({ treatPendingAsSignedOut }); - if (!userId) { - return null; - } - return children; -} +import { withClerk, type WithClerkProp } from './utils'; const $isLoadingClerkStore = computed($csrState, state => state.isLoaded); @@ -69,70 +49,44 @@ export const ClerkLoading = ({ children }: React.PropsWithChildren): JSX.Element return <>{children}; }; -export type ProtectProps = React.PropsWithChildren< - _ProtectProps & { fallback?: React.ReactNode } & PendingSessionOptions +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. - * - * Examples: - * ``` - * - * - * has({permission:"a_permission_key"})} /> - * has({role:"a_role_key"})} /> - * Unauthorized

} /> - * ``` - */ -export const Protect = ({ children, fallback, treatPendingAsSignedOut, ...restAuthorizedParams }: ProtectProps) => { - const { isLoaded, has, userId } = useAuth({ treatPendingAsSignedOut }); +export const Show = ({ children, fallback, treatPendingAsSignedOut, when }: ShowProps) => { + if (typeof when === 'undefined') { + throw new Error('@clerk/astro: requires a `when` prop.'); + } + + 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 authorized = <>{children}; const unauthorized = <>{fallback ?? null}; - const authorized = <>{children}; + if (when === '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 (when === 'signedIn') { + return authorized; } - if ( - restAuthorizedParams.role || - restAuthorizedParams.permission || - restAuthorizedParams.feature || - restAuthorizedParams.plan - ) { - if (has?.(restAuthorizedParams)) { - return authorized; - } - return unauthorized; + if (typeof when === 'function') { + return when(has) ? authorized : unauthorized; } - /** - * If neither of the authorization params are passed behave as the ``. - * If fallback is present render that instead of rendering nothing. - */ - return authorized; + return has(when) ? authorized : unauthorized; }; /** @@ -140,7 +94,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/astro/src/types.ts b/packages/astro/src/types.ts index 9de65b01579..7f0613e5968 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -3,7 +3,8 @@ import type { ClerkOptions, ClientResource, MultiDomainAndOrProxyPrimitives, - ProtectProps, + ProtectParams, + ShowProps, Without, } from '@clerk/shared/types'; import type { ClerkUiConstructor } from '@clerk/shared/ui'; @@ -62,7 +63,16 @@ declare global { } } -export type { AstroClerkUpdateOptions, AstroClerkIntegrationParams, AstroClerkCreateInstanceParams, ProtectProps }; +export type { + AstroClerkUpdateOptions, + AstroClerkIntegrationParams, + AstroClerkCreateInstanceParams, + ProtectParams, + ShowProps, +}; + +// Backward compatibility alias +export type ProtectProps = ProtectParams; export type ButtonProps = { /** diff --git a/packages/chrome-extension/docs/clerk-provider.md b/packages/chrome-extension/docs/clerk-provider.md index 150922e5f17..3d2801182ba 100644 --- a/packages/chrome-extension/docs/clerk-provider.md +++ b/packages/chrome-extension/docs/clerk-provider.md @@ -4,22 +4,22 @@ ```tsx // App.tsx -import { SignedIn, SignedOut, SignInButton, UserButton } from '@clerk/chrome-extension'; +import { Show, SignInButton, UserButton } from '@clerk/chrome-extension'; function App() { return ( <>
- + - - + + - +
- Please Sign In - Welcome! + Please Sign In + Welcome!
); @@ -61,7 +61,7 @@ export default IndexPopup; You can hook into the router of your choice to handle navigation. Here's an example using `react-router-dom`: ```tsx -import { ClerkProvider } from '@clerk/chrome-extension'; +import { ClerkProvider, Show, SignIn, SignUp } from '@clerk/chrome-extension'; import { useNavigate, Routes, Route, MemoryRouter } from 'react-router-dom'; import App from './App'; @@ -80,13 +80,13 @@ function AppWithRouting() { path='/' element={ <> - Welcome User! - + Welcome User! + - +
} /> 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..9848db006d1 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -15,20 +15,18 @@ exports[`public exports > should not include a breaking change 1`] = ` "OrganizationProfile", "OrganizationSwitcher", "PricingTable", - "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", "RedirectToUserProfile", + "Show", "SignIn", "SignInButton", "SignInWithMetamaskButton", "SignOutButton", "SignUp", "SignUpButton", - "SignedIn", - "SignedOut", "UserAvatar", "UserButton", "UserProfile", diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts index 2838dc6264b..d05f4a29ba5 100644 --- a/packages/chrome-extension/src/react/re-exports.ts +++ b/packages/chrome-extension/src/react/re-exports.ts @@ -10,20 +10,18 @@ export { OrganizationProfile, OrganizationSwitcher, PricingTable, - Protect, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, + Show, SignIn, SignInButton, SignInWithMetamaskButton, SignOutButton, SignUp, SignUpButton, - SignedIn, - SignedOut, UserAvatar, UserButton, UserProfile, diff --git a/packages/expo/src/components/controlComponents.tsx b/packages/expo/src/components/controlComponents.tsx index bc42b9dbc73..5ef4f45e015 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 } from '@clerk/react'; diff --git a/packages/nextjs/src/app-router/server/__tests__/controlComponents.test-d.ts b/packages/nextjs/src/app-router/server/__tests__/controlComponents.test-d.ts new file mode 100644 index 00000000000..1c054077b7a --- /dev/null +++ b/packages/nextjs/src/app-router/server/__tests__/controlComponents.test-d.ts @@ -0,0 +1,8 @@ +import type { ShowWhenCondition } from '@clerk/shared/types'; +import { test } from 'vitest'; + +test('ShowWhenCondition rejects empty authorization objects', () => { + // @ts-expect-error - empty object must not satisfy ShowWhenCondition/ProtectParams + const when: ShowWhenCondition = {}; + void when; +}); 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..c10416a1633 100644 --- a/packages/nextjs/src/app-router/server/controlComponents.tsx +++ b/packages/nextjs/src/app-router/server/controlComponents.tsx @@ -1,71 +1,66 @@ -import type { ProtectProps } from '@clerk/react'; -import type { PendingSessionOptions } from '@clerk/shared/types'; +import type { PendingSessionOptions, ShowWhenCondition } from '@clerk/shared/types'; import React from 'react'; import { auth } from './auth'; -export async function SignedIn( - props: React.PropsWithChildren, -): Promise { - const { children } = props; - const { userId } = await auth({ treatPendingAsSignedOut: props.treatPendingAsSignedOut }); - return userId ? <>{children} : null; -} - -export async function SignedOut( - props: React.PropsWithChildren, -): Promise { - const { children } = props; - const { userId } = await auth({ treatPendingAsSignedOut: props.treatPendingAsSignedOut }); - return userId ? null : <>{children}; -} +export type AppRouterShowProps = React.PropsWithChildren< + PendingSessionOptions & { + fallback?: React.ReactNode; + when: ShowWhenCondition; + } +>; /** - * Use `` in order to prevent unauthenticated or unauthorized users from accessing the children passed to the component. + * Use `` to render children when an authorization or sign-in condition passes. + * When `treatPendingAsSignedOut` is true, pending sessions are treated as signed out. + * Renders the provided `fallback` (or `null`) when the condition fails. * - * Examples: - * ``` - * - * - * has({permission:"a_permission_key"})} /> - * has({role:"a_role_key"})} /> - * Unauthorized

} /> + * The `when` prop supports: + * - `"signedIn"` or `"signedOut"` shorthands + * - Authorization objects such as `{ permission: "..." }`, `{ role: "..." }`, `{ feature: "..." }`, or `{ plan: "..." }` + * - Predicate functions `(has) => boolean` that receive the `has` helper + * + * @example + * ```tsx + * Unauthorized

}> + * + *
+ * + * + * + * + * + * has({ permission: "org:read" }) && isFeatureEnabled}> + * + * + * + * + * + * * ``` */ -export async function Protect(props: ProtectProps): Promise { - const { children, fallback, ...restAuthorizedParams } = props; - const { has, userId } = await auth({ treatPendingAsSignedOut: props.treatPendingAsSignedOut }); +export async function Show(props: AppRouterShowProps): Promise { + const { children, fallback, treatPendingAsSignedOut, when } = props; + const { has, userId } = await auth({ treatPendingAsSignedOut }); - /** - * Fallback to UI provided by user or `null` if authorization checks failed - */ + const resolvedWhen = when; + const authorized = <>{children}; const unauthorized = fallback ? <>{fallback} : null; - const authorized = <>{children}; + if (typeof resolvedWhen === 'string') { + if (resolvedWhen === 'signedOut') { + return userId ? unauthorized : authorized; + } + return userId ? authorized : unauthorized; + } if (!userId) { return unauthorized; } - /** - * Check against the results of `has` called inside the callback - */ - if (typeof restAuthorizedParams.condition === 'function') { - return restAuthorizedParams.condition(has) ? authorized : unauthorized; - } - - if ( - restAuthorizedParams.role || - restAuthorizedParams.permission || - restAuthorizedParams.feature || - restAuthorizedParams.plan - ) { - return has(restAuthorizedParams) ? authorized : unauthorized; + if (typeof resolvedWhen === 'function') { + return resolvedWhen(has) ? authorized : unauthorized; } - /** - * If neither of the authorization params are passed behave as the ``. - * If fallback is present render that instead of rendering nothing. - */ - return authorized; + 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..544c2e10145 100644 --- a/packages/nextjs/src/client-boundary/controlComponents.ts +++ b/packages/nextjs/src/client-boundary/controlComponents.ts @@ -1,20 +1,18 @@ 'use client'; export { - ClerkLoaded, - ClerkLoading, + AuthenticateWithRedirectCallback, ClerkDegraded, ClerkFailed, - SignedOut, - SignedIn, - Protect, + ClerkLoaded, + ClerkLoading, + RedirectToCreateOrganization, + RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, - AuthenticateWithRedirectCallback, - RedirectToCreateOrganization, - RedirectToOrganizationProfile, + Show, } 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..4635a9f1367 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, Protect } from './client-boundary/controlComponents'; +export { Show } from './client-boundary/controlComponents'; diff --git a/packages/nextjs/src/components.server.ts b/packages/nextjs/src/components.server.ts index f73c8cc91c5..11eab24d2e6 100644 --- a/packages/nextjs/src/components.server.ts +++ b/packages/nextjs/src/components.server.ts @@ -1,11 +1,9 @@ import { ClerkProvider } from './app-router/server/ClerkProvider'; -import { Protect, SignedIn, SignedOut } from './app-router/server/controlComponents'; +import { Show } from './app-router/server/controlComponents'; -export { ClerkProvider, SignedOut, SignedIn, Protect }; +export { ClerkProvider, Show }; export type ServerComponentsServerModuleTypes = { ClerkProvider: typeof ClerkProvider; - SignedIn: typeof SignedIn; - SignedOut: typeof SignedOut; - Protect: typeof Protect; + Show: typeof Show; }; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index b9c24e9b7ce..c4123f6729c 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -73,6 +73,4 @@ import * as ComponentsModule from '#components'; import type { ServerComponentsServerModuleTypes } from './components.server'; export const ClerkProvider = ComponentsModule.ClerkProvider as ServerComponentsServerModuleTypes['ClerkProvider']; -export const SignedIn = ComponentsModule.SignedIn as ServerComponentsServerModuleTypes['SignedIn']; -export const SignedOut = ComponentsModule.SignedOut as ServerComponentsServerModuleTypes['SignedOut']; -export const Protect = ComponentsModule.Protect as ServerComponentsServerModuleTypes['Protect']; +export const Show = ComponentsModule.Show as ServerComponentsServerModuleTypes['Show']; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index c5d42b4b6c3..0f0fb72e6f0 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -175,14 +175,12 @@ export default defineNuxtModule({ // Control Components 'ClerkLoaded', 'ClerkLoading', - 'Protect', 'RedirectToSignIn', 'RedirectToSignUp', 'RedirectToUserProfile', 'RedirectToOrganizationProfile', 'RedirectToCreateOrganization', - 'SignedIn', - 'SignedOut', + 'Show', 'Waitlist', ]; otherComponents.forEach(component => { diff --git a/packages/nuxt/src/runtime/components/index.ts b/packages/nuxt/src/runtime/components/index.ts index 61bde896c00..5d4cf17560a 100644 --- a/packages/nuxt/src/runtime/components/index.ts +++ b/packages/nuxt/src/runtime/components/index.ts @@ -9,9 +9,7 @@ export { // Control components ClerkLoaded, ClerkLoading, - SignedOut, - SignedIn, - Protect, + Show, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, 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..b1fb6544b7b 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -29,21 +29,19 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "OrganizationProfile", "OrganizationSwitcher", "PricingTable", - "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", "RedirectToTasks", "RedirectToUserProfile", + "Show", "SignIn", "SignInButton", "SignInWithMetamaskButton", "SignOutButton", "SignUp", "SignUpButton", - "SignedIn", - "SignedOut", "TaskChooseOrganization", "TaskResetPassword", "UserAvatar", diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index f095bcc77ff..bc041c275be 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -7,27 +7,26 @@ import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../ut import { withClerk } from './withClerk'; /** - * A button component that opens the Clerk Checkout drawer when clicked. This component must be rendered - * inside a `` component to ensure the user is authenticated. + * A button component that opens the Clerk Checkout drawer when clicked. Render only when the user is signed in (e.g., wrap with ``). * * @example * ```tsx - * import { SignedIn } from '@clerk/react'; + * import { Show } from '@clerk/react'; * import { CheckoutButton } from '@clerk/react/experimental'; * * // Basic usage with default "Checkout" text * function BasicCheckout() { * return ( - * + * * - * + * * ); * } * * // Custom button with organization subscription * function OrganizationCheckout() { * return ( - * + * * * * - * + *
* ); * } * ``` * - * @throws {Error} When rendered outside of a `` component + * @throws {Error} When rendered while the user is signed out * @throws {Error} When `for="organization"` is used without an active organization context * * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. @@ -61,7 +60,9 @@ export const CheckoutButton = withClerk( const { userId, orgId } = useAuth(); if (userId === null) { - throw new Error('Clerk: Ensure that `` is rendered inside a `` component.'); + throw new Error( + 'Clerk: Ensure that `` is rendered only when the user is signed in (wrap with `` or guard with `useAuth()`).', + ); } if (orgId === null && _for === 'organization') { diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx index 4ad2cb4ad1c..cfcd72b3d12 100644 --- a/packages/react/src/components/PlanDetailsButton.tsx +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -11,22 +11,22 @@ import { withClerk } from './withClerk'; * * @example * ```tsx - * import { SignedIn } from '@clerk/react'; + * import { Show } from '@clerk/react'; * import { PlanDetailsButton } from '@clerk/react/experimental'; * * // Basic usage with default "Plan details" text * function BasicPlanDetails() { - * return ( - * - * ); + * return ; * } * * // Custom button with custom text * function CustomPlanDetails() { * return ( - * - * - * + * + * + * + * + * * ); * } * ``` diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx index 59e04a35f43..bce5269942f 100644 --- a/packages/react/src/components/SubscriptionDetailsButton.tsx +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -7,34 +7,34 @@ import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../ut import { withClerk } from './withClerk'; /** - * A button component that opens the Clerk Subscription Details drawer when clicked. This component must be rendered inside a `` component to ensure the user is authenticated. + * A button component that opens the Clerk Subscription Details drawer when clicked. Render only when the user is signed in (e.g., wrap with ``). * * @example * ```tsx - * import { SignedIn } from '@clerk/react'; + * import { Show } from '@clerk/react'; * import { SubscriptionDetailsButton } from '@clerk/react/experimental'; * * // Basic usage with default "Subscription details" text * function BasicSubscriptionDetails() { - * return ( - * - * ); + * return ; * } * * // Custom button with Organization Subscription * function OrganizationSubscriptionDetails() { * return ( - * console.log('Subscription canceled')} - * > - * - * + * + * console.log('Subscription canceled')} + * > + * + * + * * ); * } * ``` * - * @throws {Error} When rendered outside of a `` component + * @throws {Error} When rendered while the user is signed out * @throws {Error} When `for="organization"` is used without an Active Organization context * * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes. @@ -53,7 +53,7 @@ export const SubscriptionDetailsButton = withClerk( if (userId === null) { throw new Error( - 'Clerk: Ensure that `` is rendered inside a `` component.', + 'Clerk: Ensure that `` is rendered only when the user is signed in (wrap with `` or guard with `useAuth()`).', ); } diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx index 94bbf8172c2..6a921c4a9a4 100644 --- a/packages/react/src/components/__tests__/CheckoutButton.test.tsx +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -46,7 +46,7 @@ describe('CheckoutButton', () => { // Expect the component to throw an error expect(() => render()).toThrow( - 'Ensure that `` is rendered inside a `` component.', + 'Ensure that `` is rendered only when the user is signed in (wrap with `` or guard with `useAuth()`).', ); }); diff --git a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx index 96b2d479192..800cfa9ba13 100644 --- a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx @@ -46,7 +46,7 @@ describe('SubscriptionDetailsButton', () => { // Expect the component to throw an error expect(() => render()).toThrow( - 'Ensure that `` is rendered inside a `` component.', + 'Ensure that `` is rendered only when the user is signed in (wrap with `` or guard with `useAuth()`).', ); }); diff --git a/packages/react/src/components/controlComponents.tsx b/packages/react/src/components/controlComponents.tsx index bdeefbfa05a..eca08e7ec90 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'; @@ -13,26 +9,6 @@ import { useAssertWrappedByClerkProvider } from '../hooks/useAssertWrappedByCler import type { RedirectToSignInProps, RedirectToSignUpProps, RedirectToTasksProps, WithClerkProp } from '../types'; import { withClerk } from './withClerk'; -export const SignedIn = ({ children, treatPendingAsSignedOut }: React.PropsWithChildren) => { - useAssertWrappedByClerkProvider('SignedIn'); - - const { userId } = useAuth({ treatPendingAsSignedOut }); - if (userId) { - return children; - } - return null; -}; - -export const SignedOut = ({ children, treatPendingAsSignedOut }: React.PropsWithChildren) => { - useAssertWrappedByClerkProvider('SignedOut'); - - const { userId } = useAuth({ treatPendingAsSignedOut }); - if (userId === null) { - return children; - } - return null; -}; - export const ClerkLoaded = ({ children }: React.PropsWithChildren) => { useAssertWrappedByClerkProvider('ClerkLoaded'); @@ -73,76 +49,81 @@ 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. + * Returns `null` while auth is loading. Set `treatPendingAsSignedOut` to treat + * pending sessions as signed out during that period. * - * Examples: - * ``` - * - * - * has({permission:"a_permission_key"})} /> - * has({role:"a_role_key"})} /> - * Unauthorized

} /> + * The `when` prop supports: + * - `"signedIn"` or `"signedOut"` shorthands + * - Authorization descriptors (e.g., `{ permission: "org:billing:manage" }`, `{ role: "admin" }`) + * - A predicate function `(has) => boolean` that receives the `has` helper + * + * @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..c200f386236 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -22,18 +22,16 @@ export { ClerkFailed, ClerkLoaded, ClerkLoading, - Protect, RedirectToCreateOrganization, RedirectToOrganizationProfile, RedirectToSignIn, RedirectToSignUp, RedirectToTasks, RedirectToUserProfile, - SignedIn, - SignedOut, + Show, } 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/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts index 6ca07b297f1..b31268e337e 100644 --- a/packages/shared/src/react/hooks/useCheckout.ts +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -22,7 +22,7 @@ export const useCheckout = (options?: UseCheckoutParams): CheckoutSignalValue => const clerk = useClerkInstanceContext(); if (user === null && isLoaded) { - throw new Error('Clerk: Ensure that `useCheckout` is inside a component wrapped with ``.'); + throw new Error('Clerk: Ensure that `useCheckout` is inside a component wrapped with ``.'); } if (isLoaded && forOrganization === 'organization' && organization === null) { diff --git a/packages/shared/src/types/authorization.ts b/packages/shared/src/types/authorization.ts new file mode 100644 index 00000000000..9d7552fa4be --- /dev/null +++ b/packages/shared/src/types/authorization.ts @@ -0,0 +1,82 @@ +import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; +import type { CheckAuthorizationWithCustomPermissions, PendingSessionOptions } from './session'; +import type { Autocomplete } from './utils'; + +/** + * Authorization parameters used by `auth.protect()`. + * + * Use `ProtectParams` to specify the required role, permission, feature, or plan for access. + */ +export type ProtectParams = + | { + condition?: never; + feature?: never; + permission?: never; + plan?: never; + role: OrganizationCustomRoleKey; + } + | { + condition?: never; + feature?: never; + permission: OrganizationCustomPermissionKey; + plan?: never; + role?: never; + } + | { + condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; + feature?: never; + permission?: never; + plan?: never; + role?: never; + } + | { + condition?: never; + feature: Autocomplete<`user:${string}` | `org:${string}`>; + permission?: never; + plan?: never; + role?: never; + } + | { + condition?: never; + feature?: never; + permission?: never; + plan: Autocomplete<`user:${string}` | `org:${string}`>; + role?: never; + }; + +/** + * 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/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 85cecc41a27..d1f50cfde7c 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -44,7 +44,7 @@ export type * from './passwords'; export type * from './permission'; export type * from './phoneCodeChannel'; export type * from './phoneNumber'; -export type * from './protect'; +export type * from './authorization'; export type * from './protectConfig'; export type * from './redirects'; export type * from './resource'; diff --git a/packages/shared/src/types/protect.ts b/packages/shared/src/types/protect.ts deleted file mode 100644 index 0498c2b5f1b..00000000000 --- a/packages/shared/src/types/protect.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { OrganizationCustomPermissionKey, OrganizationCustomRoleKey } from './organizationMembership'; -import type { CheckAuthorizationWithCustomPermissions } from './session'; -import type { Autocomplete } from './utils'; - -/** - * Props for the `` component, which restricts access to its children based on authentication and authorization. - * - * Use `ProtectProps` to specify the required Role, Permission, Feature, or Plan for access. - * - * @example - * ```tsx - * // Require a specific Permission - * - * - * // Require a specific Role - * - * - * // Use a custom condition callback - * has({ permission: "a_permission_key" })} /> - * - * // Require a specific Feature - * - * - * // Require a specific Plan - * - * ``` - */ -export type ProtectProps = - | { - condition?: never; - role: OrganizationCustomRoleKey; - permission?: never; - feature?: never; - plan?: never; - } - | { - condition?: never; - role?: never; - feature?: never; - plan?: never; - permission: OrganizationCustomPermissionKey; - } - | { - condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; - role?: never; - permission?: never; - feature?: never; - plan?: never; - } - | { - condition?: never; - role?: never; - permission?: never; - feature: Autocomplete<`user:${string}` | `org:${string}`>; - plan?: never; - } - | { - condition?: never; - role?: never; - permission?: never; - feature?: never; - plan: Autocomplete<`user:${string}` | `org:${string}`>; - } - | { - condition?: never; - role?: never; - permission?: never; - feature?: never; - plan?: never; - }; 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..eaba504c812 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,21 +34,19 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "OrganizationProfile", "OrganizationSwitcher", "PricingTable", - "Protect", "RedirectToCreateOrganization", "RedirectToOrganizationProfile", "RedirectToSignIn", "RedirectToSignUp", "RedirectToTasks", "RedirectToUserProfile", + "Show", "SignIn", "SignInButton", "SignInWithMetamaskButton", "SignOutButton", "SignUp", "SignUpButton", - "SignedIn", - "SignedOut", "TaskChooseOrganization", "TaskResetPassword", "UserAvatar", 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..c9115431792 --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-protect-to-show.fixtures.js @@ -0,0 +1,584 @@ +export const fixtures = [ + { + name: 'Transforms Protect import', + source: ` +import { Protect } from "@clerk/react" + `, + output: ` +import { Show } from "@clerk/react" +`, + }, + { + name: 'Transforms Protect import from legacy package', + source: ` +import { Protect } from "@clerk/clerk-react" + `, + output: ` +import { Show } from "@clerk/clerk-react" +`, + }, + { + name: 'Transforms SignedIn and SignedOut imports', + source: ` +import { SignedIn, SignedOut } from "@clerk/react" + `, + output: ` +import { Show } from "@clerk/react"; +`, + }, + { + name: 'Transforms Protect in TSX', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + + + + ); +} +`, + }, + { + name: 'Transforms SignedIn usage', + source: ` +import { SignedIn } from "@clerk/react" + +const App = () => ( + +
Child
+
+) + `, + output: ` +import { Show } from "@clerk/react" + +const App = () => ( + +
Child
+
+) +`, + }, + { + name: 'Transforms SignedOut usage', + source: ` +import { SignedOut } from "@clerk/react" + +const App = () => ( + +
Child
+
+) + `, + output: ` +import { Show } from "@clerk/react" + +const App = () => ( + +
Child
+
+) +`, + }, + { + name: 'Transforms SignedIn namespace import', + source: ` +import * as Clerk from "@clerk/react" + +const App = () => ( + +
Child
+
+) + `, + output: ` +import * as Clerk from "@clerk/react" + +const App = () => ( + +
Child
+
+) +`, + }, + { + name: 'Transforms Protect condition callback', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + has({ role: "admin" })}> + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + has({ role: "admin" })}> + + + ); +} +`, + }, + { + name: 'Transforms SignedIn import with other specifiers', + source: ` +import { ClerkProvider, SignedIn } from "@clerk/nextjs" + `, + output: ` +import { ClerkProvider, Show } from "@clerk/nextjs" +`, + }, + { + name: 'Transforms ProtectProps type', + source: ` +import { ProtectProps } from "@clerk/react"; +type Props = ProtectProps; + `, + output: ` +import { ShowProps } from "@clerk/react"; +type Props = ShowProps; +`, + }, + { + name: 'Self-closing Protect defaults to signedIn', + source: ` +import { Protect } from "@clerk/react" + +const Thing = () => + `, + output: ` +import { Show } from "@clerk/react" + +const Thing = () => +`, + }, + { + name: 'Transforms Protect from hybrid package without client directive', + source: ` +import { Protect } from "@clerk/nextjs" + +const App = () => ( + +
Child
+
+) + `, + output: ` +import { Show } from "@clerk/nextjs" + +const App = () => ( + +
Child
+
+) +`, + }, + { + name: 'Transforms SignedOut to Show with fallback prop', + source: ` +import { SignedOut } from "@clerk/react" + +const App = () => ( + }> +
Child
+
+) + `, + output: ` +import { Show } from "@clerk/react" + +const App = () => ( + }> +
Child
+
+) +`, + }, + { + name: 'Transforms SignedOut namespace import with fallback', + source: ` +import * as Clerk from "@clerk/react" + +const App = () => ( + }> +
Child
+
+) + `, + output: ` +import * as Clerk from "@clerk/react" + +const App = () => ( + }> +
Child
+
+) +`, + }, + { + name: 'Aliased Protect import is transformed', + source: ` +import { Protect as CanAccess } from "@clerk/react" + +function App() { + return ( + + + + ) +} + `, + output: ` +import { Show as CanAccess } from "@clerk/react" + +function App() { + return ( + + + + ); +} +`, + }, + { + name: 'ProtectProps type aliases update', + source: ` +import { ProtectProps } from "@clerk/react"; +type Props = ProtectProps; +type Another = ProtectProps; + `, + output: ` +import { ShowProps } from "@clerk/react"; +type Props = ShowProps; +type Another = ShowProps; +`, + }, + { + name: 'Protect with fallback prop', + source: ` +import { Protect } from "@clerk/react" + +function App() { + return ( + }> + + + ) +} + `, + output: ` +import { Show } from "@clerk/react" + +function App() { + return ( + }> + + + ); +} +`, + }, + { + name: 'Protect with spread props', + source: ` +import { Protect } from "@clerk/react" + +const props = { permission: "org:read" } +const App = () => + `, + output: ` +import { Show } from "@clerk/react" + +const props = { permission: "org:read" } +const App = () => +`, + }, + { + name: 'Transforms Protect require destructuring', + source: ` +const { Protect } = require("@clerk/react"); + +function App() { + return ok; +} + `, + output: ` +const { Show } = require("@clerk/react"); + +function App() { + return ( + ok + ); +} +`, + }, + { + name: 'Transforms SignedIn and SignedOut require destructuring', + source: ` +const { SignedIn, SignedOut } = require("@clerk/react"); + +const App = () => ( + <> + in + out + +); + `, + output: ` +const { + Show +} = require("@clerk/react"); + +const App = () => ( + <> + in + out + +); +`, + }, + { + name: 'Transforms namespace require', + source: ` +const Clerk = require("@clerk/react"); + +const App = () => ( + + ok + +); + `, + output: ` +const Clerk = require("@clerk/react"); + +const App = () => ( + + ok + +); +`, + }, + { + name: 'Transforms Protect from other @clerk packages', + source: ` +import { Protect as ProtectExpo } from "@clerk/expo"; +import { Protect as ProtectVue } from "@clerk/vue"; +import { Protect as ProtectChrome } from "@clerk/chrome-extension"; + `, + output: ` +import { Show as ProtectExpo } from "@clerk/expo"; +import { Show as ProtectVue } from "@clerk/vue"; +import { Show as ProtectChrome } from "@clerk/chrome-extension"; +`, + }, + { + name: 'Transforms default import member usage', + source: ` +import Clerk from "@clerk/react"; + +const App = () => ( + + ok + +); + `, + output: ` +import Clerk from "@clerk/react"; + +const App = () => ( + + ok + +); +`, + }, + { + name: 'Transforms Protect namespace import member usage', + source: ` +import * as Clerk from "@clerk/react"; + +const App = () => ( + + ok + +); + `, + output: ` +import * as Clerk from "@clerk/react"; + +const App = () => ( + + ok + +); +`, + }, + { + name: 'Self-closing SignedIn and SignedOut are transformed', + source: ` +import { SignedIn, SignedOut } from "@clerk/react"; + +const App = () => ( + <> + + + +); + `, + output: ` +import { Show } from "@clerk/react"; + +const App = () => ( + <> + + + +); +`, + }, + { + name: 'Transforms SignedIn alias import usage', + source: ` +import { SignedIn as OnlyWhenSignedIn } from "@clerk/react"; + +const App = () => ( + + ok + +); + `, + output: ` +import { Show as OnlyWhenSignedIn } from "@clerk/react"; + +const App = () => ( + + ok + +); +`, + }, + { + name: 'Transforms Protect require destructuring with alias', + source: ` +const { Protect: CanAccess } = require("@clerk/react"); + +const App = () => ( + + ok + +); + `, + output: ` +const { Show: CanAccess } = require("@clerk/react"); + +const App = () => ( + + ok + +); +`, + }, + { + name: 'Transforms import with duplicate Show specifier', + source: ` +import { Protect, Show } from "@clerk/react"; + +const App = () => ; + `, + output: ` +import { Show } from "@clerk/react"; + +const App = () => ; +`, + }, + { + name: 'Transforms import type ProtectProps', + source: ` +import type { ProtectProps } from "@clerk/react"; +type Props = ProtectProps; + `, + output: ` +import type { ShowProps } from "@clerk/react"; +type Props = ShowProps; +`, + }, + { + name: 'Sorts when object keys for determinism', + source: ` +import { Protect } from "@clerk/react"; + +const App = () => ( + + ok + +); + `, + output: ` +import { Show } from "@clerk/react"; + +const App = () => ( + + ok + +); +`, + }, + { + name: 'Does not transform non-clerk Protect', + source: ` +import { Protect } from "./local"; + +const App = () => ( + + ok + +); + `, + output: null, + }, +]; 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..63dfe4dd72c --- /dev/null +++ b/packages/upgrade/src/codemods/transform-protect-to-show.cjs @@ -0,0 +1,350 @@ +const CLERK_PACKAGE_PREFIX = '@clerk/'; + +const isClerkPackageSource = sourceValue => { + return typeof sourceValue === 'string' && sourceValue.startsWith(CLERK_PACKAGE_PREFIX); +}; + +/** + * Transforms `` component usage to `` component. + * + * Handles the following transformations: + * - `` → `` + * - `` → `` + * - `` → `` + * - `` → `` + * - ` ...}>` → ` ...}>` + * - `...` → `...` + * - `...` → `...` + * + * Also updates ESM/CJS imports from `Protect` to `Show`. + * + * @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 componentKindByLocalName = {}; + const protectPropsLocalsToRename = []; + const namespaceImports = new Set(); + + // Transform ESM imports: Protect → Show, ProtectProps → ShowProps + root.find(j.ImportDeclaration).forEach(path => { + const node = path.node; + const sourceValue = node.source?.value; + + if (!isClerkPackageSource(sourceValue)) { + return; + } + + const specifiers = node.specifiers || []; + + specifiers.forEach(spec => { + if (j.ImportDefaultSpecifier.check(spec) || j.ImportNamespaceSpecifier.check(spec)) { + if (spec.local?.name) { + namespaceImports.add(spec.local.name); + } + return; + } + + if (!j.ImportSpecifier.check(spec)) { + return; + } + + const originalImportedName = spec.imported.name; + + if (['Protect', 'SignedIn', 'SignedOut'].includes(originalImportedName)) { + const effectiveLocalName = spec.local ? spec.local.name : originalImportedName; + componentKindByLocalName[effectiveLocalName] = + originalImportedName === 'Protect' + ? 'protect' + : originalImportedName === 'SignedIn' + ? 'signedIn' + : 'signedOut'; + spec.imported.name = 'Show'; + if (spec.local && spec.local.name === originalImportedName) { + spec.local.name = 'Show'; + } + dirtyFlag = true; + return; + } + + if (spec.imported.name === 'ProtectProps') { + const effectiveLocalName = spec.local ? spec.local.name : spec.imported.name; + spec.imported.name = 'ShowProps'; + if (spec.local && spec.local.name === 'ProtectProps') { + spec.local.name = 'ShowProps'; + } + if (effectiveLocalName === 'ProtectProps') { + protectPropsLocalsToRename.push(effectiveLocalName); + } + dirtyFlag = true; + } + }); + + const seenLocalNames = new Set(); + node.specifiers = specifiers.reduce((acc, spec) => { + let localName = null; + + if (spec.local && j.Identifier.check(spec.local)) { + localName = spec.local.name; + } else if (j.ImportSpecifier.check(spec) && j.Identifier.check(spec.imported)) { + localName = spec.imported.name; + } + + if (localName) { + if (seenLocalNames.has(localName)) { + dirtyFlag = true; + return acc; + } + seenLocalNames.add(localName); + } + + acc.push(spec); + return acc; + }, []); + }); + + // Transform CJS requires: Protect → Show + root.find(j.VariableDeclarator).forEach(path => { + const declarator = path.node; + const init = declarator.init; + + if (!init || !j.CallExpression.check(init)) { + return; + } + + if (!j.Identifier.check(init.callee) || init.callee.name !== 'require') { + return; + } + + const args = init.arguments || []; + if (args.length !== 1) { + return; + } + + const arg = args[0]; + const sourceValue = j.Literal.check(arg) ? arg.value : j.StringLiteral.check(arg) ? arg.value : null; + + if (!isClerkPackageSource(sourceValue)) { + return; + } + + const id = declarator.id; + + if (j.Identifier.check(id)) { + namespaceImports.add(id.name); + return; + } + + if (!j.ObjectPattern.check(id)) { + return; + } + + const properties = id.properties || []; + const seenLocalNames = new Set(); + + id.properties = properties.reduce((acc, prop) => { + if (!(j.ObjectProperty.check(prop) || j.Property.check(prop))) { + acc.push(prop); + return acc; + } + + if (!j.Identifier.check(prop.key)) { + acc.push(prop); + return acc; + } + + const originalImportedName = prop.key.name; + const originalLocalName = j.Identifier.check(prop.value) ? prop.value.name : null; + const effectiveLocalName = originalLocalName || originalImportedName; + + if (['Protect', 'SignedIn', 'SignedOut'].includes(originalImportedName)) { + componentKindByLocalName[effectiveLocalName] = + originalImportedName === 'Protect' + ? 'protect' + : originalImportedName === 'SignedIn' + ? 'signedIn' + : 'signedOut'; + + prop.key.name = 'Show'; + + if (j.Identifier.check(prop.value) && prop.value.name === originalImportedName) { + prop.value.name = 'Show'; + } + + if (prop.shorthand) { + prop.value = j.identifier('Show'); + } + + dirtyFlag = true; + } + + const newLocalName = j.Identifier.check(prop.value) ? prop.value.name : null; + const finalLocalName = newLocalName || (j.Identifier.check(prop.key) ? prop.key.name : null); + + if (finalLocalName) { + if (seenLocalNames.has(finalLocalName)) { + dirtyFlag = true; + return acc; + } + seenLocalNames.add(finalLocalName); + } + + acc.push(prop); + return acc; + }, []); + }); + + // Rename references to ProtectProps (only when local name was ProtectProps) + if (protectPropsLocalsToRename.length > 0) { + root + .find(j.TSTypeReference, { + typeName: { + type: 'Identifier', + name: 'ProtectProps', + }, + }) + .forEach(path => { + const typeName = path.node.typeName; + if (j.Identifier.check(typeName) && typeName.name === 'ProtectProps') { + typeName.name = 'ShowProps'; + dirtyFlag = true; + } + }); + } + + // Transform JSX: + root.find(j.JSXElement).forEach(path => { + const openingElement = path.node.openingElement; + const closingElement = path.node.closingElement; + + let kind = null; + let renameNodeToShow = null; + + if (j.JSXIdentifier.check(openingElement.name)) { + const originalName = openingElement.name.name; + kind = componentKindByLocalName[originalName]; + + if (['Protect', 'SignedIn', 'SignedOut'].includes(originalName)) { + renameNodeToShow = node => { + if (j.JSXIdentifier.check(node)) { + node.name = 'Show'; + } + }; + } + } else if (j.JSXMemberExpression.check(openingElement.name)) { + const member = openingElement.name; + if (j.Identifier.check(member.object) && j.Identifier.check(member.property)) { + const objectName = member.object.name; + const propertyName = member.property.name; + + if (namespaceImports.has(objectName) && ['Protect', 'SignedIn', 'SignedOut'].includes(propertyName)) { + kind = propertyName === 'Protect' ? 'protect' : propertyName === 'SignedIn' ? 'signedIn' : 'signedOut'; + + renameNodeToShow = node => { + if (j.JSXMemberExpression.check(node) && j.Identifier.check(node.property)) { + node.property.name = 'Show'; + } + }; + } + } + } + + if (!kind) { + return; + } + + if (renameNodeToShow) { + renameNodeToShow(openingElement.name); + if (closingElement && closingElement.name) { + renameNodeToShow(closingElement.name); + } + } + + 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 (kind === 'signedIn' || kind === 'signedOut') { + whenValue = j.stringLiteral(kind === 'signedIn' ? 'signedIn' : 'signedOut'); + } else 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 if (attr.value == null) { + value = j.booleanLiteral(true); + } else { + // Default string value + value = j.stringLiteral(attr.value?.value || ''); + } + + return j.objectProperty(key, value); + }); + + properties.sort((a, b) => { + const aKey = j.Identifier.check(a.key) ? a.key.name : ''; + const bKey = j.Identifier.check(b.key) ? b.key.name : ''; + return aKey.localeCompare(bKey); + }); + + whenValue = j.jsxExpressionContainer(j.objectExpression(properties)); + } + + // Reconstruct attributes with `when` prop + const newAttributes = []; + + const defaultWhenValue = kind === 'signedOut' ? 'signedOut' : 'signedIn'; + const finalWhenValue = whenValue || j.stringLiteral(defaultWhenValue); + + newAttributes.push(j.jsxAttribute(j.jsxIdentifier('when'), finalWhenValue)); + + // 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/CheckoutButton.vue b/packages/vue/src/components/CheckoutButton.vue index 3d5332a4e61..6774e48c452 100644 --- a/packages/vue/src/components/CheckoutButton.vue +++ b/packages/vue/src/components/CheckoutButton.vue @@ -15,7 +15,7 @@ const attrs = useAttrs(); // Authentication checks - similar to React implementation if (userId.value === null) { - throw new Error('Ensure that `` is rendered inside a `` component.'); + throw new Error('Ensure that `` is rendered inside a `` component.'); } if (orgId.value === null && props.for === 'organization') { diff --git a/packages/vue/src/components/SubscriptionDetailsButton.vue b/packages/vue/src/components/SubscriptionDetailsButton.vue index 1d3dce1819a..b41e1bd7642 100644 --- a/packages/vue/src/components/SubscriptionDetailsButton.vue +++ b/packages/vue/src/components/SubscriptionDetailsButton.vue @@ -15,7 +15,9 @@ const attrs = useAttrs(); // Authentication checks - similar to React implementation if (userId.value === null) { - throw new Error('Ensure that `` is rendered inside a `` component.'); + throw new Error( + 'Ensure that `` is rendered inside a `` component.', + ); } if (orgId.value === null && props.for === 'organization') { diff --git a/packages/vue/src/components/controlComponents.ts b/packages/vue/src/components/controlComponents.ts index 5148700900f..8422a75b1eb 100644 --- a/packages/vue/src/components/controlComponents.ts +++ b/packages/vue/src/components/controlComponents.ts @@ -2,28 +2,16 @@ import { deprecated } from '@clerk/shared/deprecated'; import type { HandleOAuthCallbackParams, PendingSessionOptions, - ProtectProps as _ProtectProps, RedirectOptions, + ShowWhenCondition, } from '@clerk/shared/types'; -import { defineComponent } from 'vue'; +import { defineComponent, type VNodeChild } from 'vue'; import { useAuth } from '../composables/useAuth'; import { useClerk } from '../composables/useClerk'; import { useClerkContext } from '../composables/useClerkContext'; import { useClerkLoaded } from '../utils/useClerkLoaded'; -export const SignedIn = defineComponent(({ treatPendingAsSignedOut }, { slots }) => { - const { userId } = useAuth({ treatPendingAsSignedOut }); - - return () => (userId.value ? slots.default?.() : null); -}); - -export const SignedOut = defineComponent(({ treatPendingAsSignedOut }, { slots }) => { - const { userId } = useAuth({ treatPendingAsSignedOut }); - - return () => (userId.value === null ? slots.default?.() : null); -}); - export const ClerkLoaded = defineComponent((_, { slots }) => { const clerk = useClerk(); @@ -112,9 +100,28 @@ export const AuthenticateWithRedirectCallback = defineComponent((props: HandleOA return () => null; }); -export type ProtectProps = _ProtectProps & PendingSessionOptions; +/** + * Props for `` that control when content renders based on sign-in or authorization state. + * + * @public + * @property fallback Optional content shown when the condition fails; can be provided via prop or `fallback` slot. + * @property when Condition controlling visibility; supports `"signedIn"`, `"signedOut"`, authorization descriptors, or a predicate that receives the `has` helper. + * @property treatPendingAsSignedOut Inherited from `PendingSessionOptions`; treat pending sessions as signed out while loading. + * @example + * ```vue + * + * + * + * + * + * + * + * + * ``` + */ +export type ShowProps = PendingSessionOptions & { fallback?: unknown; when: ShowWhenCondition }; -export const Protect = defineComponent((props: ProtectProps, { slots }) => { +export const Show = defineComponent((props: ShowProps, { slots }) => { const { isLoaded, has, userId } = useAuth({ treatPendingAsSignedOut: props.treatPendingAsSignedOut }); return () => { @@ -125,37 +132,33 @@ export const Protect = defineComponent((props: ProtectProps, { slots }) => { return null; } - /** - * Fallback to UI provided by user or `null` if authorization checks failed - */ - if (!userId.value) { - return slots.fallback?.(); + const authorized = (slots.default?.() ?? null) as VNodeChild | null; + const fallbackFromSlot = slots.fallback?.() ?? null; + const fallbackFromProp = (props.fallback as VNodeChild | null | undefined) ?? null; + const unauthorized = (fallbackFromSlot ?? fallbackFromProp ?? null) as VNodeChild | null; + + if (props.when === 'signedOut') { + return userId.value ? unauthorized : authorized; } - /** - * Check against the results of `has` called inside the callback - */ - if (typeof props.condition === 'function') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (props.condition(has.value!)) { - return slots.default?.(); - } + if (!userId.value) { + return unauthorized; + } - return slots.fallback?.(); + if (props.when === 'signedIn') { + return authorized; } - if (props.role || props.permission || props.feature || props.plan) { - if (has.value?.(props)) { - return slots.default?.(); - } + const hasValue = has.value; - return slots.fallback?.(); + if (!hasValue) { + return unauthorized; } - /** - * If neither of the authorization params are passed behave as the ``. - * If fallback is present render that instead of rendering nothing. - */ - return slots.default?.(); + if (typeof props.when === 'function') { + return props.when(hasValue) ? authorized : unauthorized; + } + + return hasValue(props.when) ? authorized : unauthorized; }; }); diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts index 65c8398137f..2aaa15af860 100644 --- a/packages/vue/src/components/index.ts +++ b/packages/vue/src/components/index.ts @@ -14,9 +14,7 @@ export { UserButton } from './ui-components/UserButton'; export { ClerkLoaded, ClerkLoading, - SignedOut, - SignedIn, - Protect, + Show, RedirectToSignIn, RedirectToSignUp, RedirectToUserProfile, diff --git a/playground/app-router/src/app/protected/page.tsx b/playground/app-router/src/app/protected/page.tsx index b93598f1d56..1d41a58bf40 100644 --- a/playground/app-router/src/app/protected/page.tsx +++ b/playground/app-router/src/app/protected/page.tsx @@ -1,4 +1,4 @@ -import { ClerkLoaded, SignedIn, SignedOut, UserButton } from '@clerk/nextjs'; +import { ClerkLoaded, Show, UserButton } from '@clerk/nextjs'; import { auth } from '@clerk/nextjs/server'; import React from 'react'; import { ClientSideWrapper } from '@/app/protected/ClientSideWrapper'; @@ -13,12 +13,12 @@ export default async function Page() {

Protected page


-      
+      
         

Signed in

-
- + +

Signed out

-
+

Clerk loaded

@@ -26,9 +26,9 @@ export default async function Page() { server content - +
SignedIn
-
+
ClerkLoaded
diff --git a/playground/app-router/src/pages/user/[[...index]].tsx b/playground/app-router/src/pages/user/[[...index]].tsx index 965be25b361..391f19f3f0c 100644 --- a/playground/app-router/src/pages/user/[[...index]].tsx +++ b/playground/app-router/src/pages/user/[[...index]].tsx @@ -1,4 +1,4 @@ -import { SignedIn, UserProfile } from '@clerk/nextjs'; +import { Show, UserProfile } from '@clerk/nextjs'; import { getAuth } from '@clerk/nextjs/server'; import type { GetServerSideProps, NextPage } from 'next'; import React from 'react'; @@ -14,9 +14,9 @@ const UserProfilePage: NextPage = (props: any) => {

/pages/user

{props.message}
- +

SignedIn

-
+
); diff --git a/playground/browser-extension/src/components/nav-bar.tsx b/playground/browser-extension/src/components/nav-bar.tsx index 828fc565a93..6d422d38b46 100644 --- a/playground/browser-extension/src/components/nav-bar.tsx +++ b/playground/browser-extension/src/components/nav-bar.tsx @@ -1,11 +1,11 @@ -import { SignedIn, SignedOut, UserButton } from "@clerk/chrome-extension" +import { Show, UserButton } from "@clerk/chrome-extension" import { Link } from "react-router-dom" import { Button } from "./ui/button" export const NavBar = () => { return ( <> - +
-
- +
+
-
+
) diff --git a/playground/expo/App.tsx b/playground/expo/App.tsx index ffa3ce37f24..d6a5d988cb3 100644 --- a/playground/expo/App.tsx +++ b/playground/expo/App.tsx @@ -1,4 +1,4 @@ -import { ClerkProvider, SignedIn, SignedOut, useAuth, useSignIn, useUser } from '@clerk/expo'; +import { ClerkProvider, Show, useAuth, useSignIn, useUser } from '@clerk/expo'; import { passkeys } from '@clerk/expo/passkeys'; import * as SecureStore from 'expo-secure-store'; import React from 'react'; @@ -145,12 +145,12 @@ export default function App() { __experimental_passkeys={passkeys} > - + - - +
+ -
+
); diff --git a/playground/nextjs/app/app-dir/client/page.tsx b/playground/nextjs/app/app-dir/client/page.tsx index 5baa35ba0b2..6191257178e 100644 --- a/playground/nextjs/app/app-dir/client/page.tsx +++ b/playground/nextjs/app/app-dir/client/page.tsx @@ -1,13 +1,11 @@ 'use client'; -import { SignedIn, SignedOut } from '@clerk/nextjs'; +import { Show } from '@clerk/nextjs'; export default function Page() { return (
- {/* @ts-ignore */} - Hello In - {/* @ts-ignore */} - Hello Out + Hello In + Hello Out
); } diff --git a/playground/nextjs/app/app-dir/page.tsx b/playground/nextjs/app/app-dir/page.tsx index 28b60975ec7..d5a773b6b36 100644 --- a/playground/nextjs/app/app-dir/page.tsx +++ b/playground/nextjs/app/app-dir/page.tsx @@ -1,4 +1,4 @@ -import { OrganizationSwitcher, SignedIn, SignedOut, SignIn, UserButton } from '@clerk/nextjs'; +import { OrganizationSwitcher, Show, SignIn, UserButton } from '@clerk/nextjs'; import { auth, clerkClient, currentUser } from '@clerk/nextjs/server'; import Link from 'next/link'; @@ -27,7 +27,7 @@ export default async function Page() {

Hello, Next.js!

{userId ?

Signed in as: {userId}

:

Signed out

} {/* @ts-ignore */} - +
{JSON.stringify(user)}
{JSON.stringify(currentUser_)}
-
+
{/* @ts-ignore */} - + - +
); diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx index 2aa8a84e7cf..88b9b4ded35 100644 --- a/playground/nextjs/pages/_app.tsx +++ b/playground/nextjs/pages/_app.tsx @@ -4,8 +4,7 @@ import '../styles/globals.css'; import { ClerkProvider, OrganizationSwitcher, - SignedIn, - SignedOut, + Show, SignInButton, SignOutButton, UserButton, @@ -156,14 +155,14 @@ const AppBar = (props: AppBarProps) => { {/* @ts-ignore */} - + - + {/* @ts-ignore */} - + - + ); }; diff --git a/playground/react-router/app/root.tsx b/playground/react-router/app/root.tsx index bb6fb1e5f66..983723cb1a3 100644 --- a/playground/react-router/app/root.tsx +++ b/playground/react-router/app/root.tsx @@ -7,7 +7,7 @@ import { ScrollRestoration, } from "react-router"; import { rootAuthLoader } from "@clerk/react-router/ssr.server"; -import { ClerkProvider, SignedIn, SignedOut, UserButton, SignInButton } from "@clerk/react-router"; +import { ClerkProvider, Show, SignInButton, UserButton } from "@clerk/react-router"; import type { Route } from "./+types/root"; import stylesheet from "./app.css?url"; @@ -52,12 +52,12 @@ export default function App({ loaderData }: Route.ComponentProps) { return (
- + - - + + - +
diff --git a/playground/vite-react-ts/src/App.tsx b/playground/vite-react-ts/src/App.tsx index 14bea78dc23..acca91648c3 100644 --- a/playground/vite-react-ts/src/App.tsx +++ b/playground/vite-react-ts/src/App.tsx @@ -1,8 +1,7 @@ import { ClerkProvider, RedirectToSignIn, - SignedIn, - SignedOut, + Show, SignIn, SignUp, UserButton, @@ -126,12 +125,12 @@ function ClerkProviderWithRoutes() { path='/protected' element={ <> - + - - + + - + } />