diff --git a/.changeset/gold-snails-care.md b/.changeset/gold-snails-care.md new file mode 100644 index 00000000000..f45c7a4ea52 --- /dev/null +++ b/.changeset/gold-snails-care.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-react': patch +--- + +[Experimental] Fix issue with property access for state proxy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3ddf289db2..a86875008ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -293,7 +293,8 @@ jobs: 'nuxt', 'react-router', 'billing', - 'machine' + 'machine', + 'custom', ] test-project: ['chrome'] include: diff --git a/integration/presets/custom-flows.ts b/integration/presets/custom-flows.ts new file mode 100644 index 00000000000..bda524479f6 --- /dev/null +++ b/integration/presets/custom-flows.ts @@ -0,0 +1,19 @@ +import { constants } from '../constants'; +import { applicationConfig } from '../models/applicationConfig'; +import { templates } from '../templates'; +import { linkPackage } from './utils'; + +const reactVite = applicationConfig() + .setName('custom-flows-react-vite') + .useTemplate(templates['custom-flows-react-vite']) + .setEnvFormatter('public', key => `VITE_${key}`) + .addScript('setup', 'pnpm install') + .addScript('dev', 'pnpm dev') + .addScript('build', 'pnpm build') + .addScript('serve', 'pnpm preview') + .addDependency('@clerk/clerk-react', constants.E2E_CLERK_VERSION || linkPackage('react')) + .addDependency('@clerk/themes', constants.E2E_CLERK_VERSION || linkPackage('themes')); + +export const customFlows = { + reactVite, +} as const; diff --git a/integration/presets/index.ts b/integration/presets/index.ts index 08bb8abed3d..5048abef518 100644 --- a/integration/presets/index.ts +++ b/integration/presets/index.ts @@ -1,4 +1,5 @@ import { astro } from './astro'; +import { customFlows } from './custom-flows'; import { elements } from './elements'; import { envs, instanceKeys } from './envs'; import { expo } from './expo'; @@ -12,6 +13,7 @@ import { tanstack } from './tanstack'; import { vue } from './vue'; export const appConfigs = { + customFlows, envs, express, longRunningApps: createLongRunningApps(), diff --git a/integration/templates/custom-flows-react-vite/.gitignore b/integration/templates/custom-flows-react-vite/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/integration/templates/custom-flows-react-vite/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/integration/templates/custom-flows-react-vite/components.json b/integration/templates/custom-flows-react-vite/components.json new file mode 100644 index 00000000000..13e1db0b7a1 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/integration/templates/custom-flows-react-vite/eslint.config.js b/integration/templates/custom-flows-react-vite/eslint.config.js new file mode 100644 index 00000000000..e821a89d6d5 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { globalIgnores } from 'eslint/config'; + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]); diff --git a/integration/templates/custom-flows-react-vite/index.html b/integration/templates/custom-flows-react-vite/index.html new file mode 100644 index 00000000000..e4b78eae123 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/integration/templates/custom-flows-react-vite/package.json b/integration/templates/custom-flows-react-vite/package.json new file mode 100644 index 00000000000..31bfde81a54 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/package.json @@ -0,0 +1,40 @@ +{ + "name": "hooks-revamp-vite-react", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite --port $PORT --no-open", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.1.11", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.539.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router": "^7.8.1", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@types/node": "^24.2.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "tw-animate-css": "^1.3.6", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.0.4" + } +} diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/button.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/button.tsx new file mode 100644 index 00000000000..0c4c9a7d343 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + destructive: + 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<'button'> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/card.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/card.tsx new file mode 100644 index 00000000000..961d8844f2e --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/components/ui/card.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/input.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/input.tsx new file mode 100644 index 00000000000..bace6d65566 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/label.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/label.tsx new file mode 100644 index 00000000000..f0d7164be5e --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; + +import { cn } from '@/lib/utils'; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/integration/templates/custom-flows-react-vite/src/index.css b/integration/templates/custom-flows-react-vite/src/index.css new file mode 100644 index 00000000000..7550e245bf6 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/index.css @@ -0,0 +1,120 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/integration/templates/custom-flows-react-vite/src/lib/utils.ts b/integration/templates/custom-flows-react-vite/src/lib/utils.ts new file mode 100644 index 00000000000..2819a830d24 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx new file mode 100644 index 00000000000..7b170e17b18 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/main.tsx @@ -0,0 +1,50 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter, Route, Routes } from 'react-router'; +import './index.css'; +import { ClerkProvider } from '@clerk/clerk-react'; +import { Home } from './routes/Home'; +import { SignIn } from './routes/SignIn'; +import { SignUp } from './routes/SignUp'; +import { Protected } from './routes/Protected'; + +// Import your Publishable Key +const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!PUBLISHABLE_KEY) { + throw new Error('Add your Clerk Publishable Key to the .env file'); +} + +createRoot(document.getElementById('root')!).render( + +
+
+ + + + } + /> + } + /> + } + /> + } + /> + + + +
+
+
, +); diff --git a/integration/templates/custom-flows-react-vite/src/routes/Home.tsx b/integration/templates/custom-flows-react-vite/src/routes/Home.tsx new file mode 100644 index 00000000000..2ce81082a77 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/Home.tsx @@ -0,0 +1,10 @@ +import { NavLink } from 'react-router'; + +export function Home() { + return ( +
+ Sign In + Sign Up +
+ ); +} diff --git a/integration/templates/custom-flows-react-vite/src/routes/Protected.tsx b/integration/templates/custom-flows-react-vite/src/routes/Protected.tsx new file mode 100644 index 00000000000..1f937b66941 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/Protected.tsx @@ -0,0 +1,15 @@ +import { useUser } from '@clerk/clerk-react'; + +export function Protected() { + const { user, isLoaded } = useUser(); + if (!isLoaded || !user) { + return; + } + + return ( +
+

Protected

+

{user.emailAddresses[0].emailAddress}

+
+ ); +} diff --git a/integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx b/integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx new file mode 100644 index 00000000000..60f08d1734c --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useUser } from '@clerk/clerk-react'; +import { useSignInSignal } from '@clerk/clerk-react/experimental'; +import { useState } from 'react'; +import { NavLink, useNavigate } from 'react-router'; + +type AvailableStrategy = 'email_code' | 'phone_code' | 'password' | 'reset_password_email_code'; + +export function SignIn({ className, ...props }: React.ComponentProps<'div'>) { + const { signIn, errors, fetchStatus } = useSignInSignal(); + const [selectedStrategy, setSelectedStrategy] = useState(null); + const { isSignedIn } = useUser(); + const navigate = useNavigate(); + + const handleOauth = async (strategy: 'oauth_google') => { + await signIn.sso({ + strategy, + redirectUrl: '/sso-callback', + redirectUrlComplete: '/protected', + }); + }; + + const handleSubmit = async (formData: FormData) => { + const identifier = formData.get('identifier'); + if (!identifier) { + return; + } + + await signIn.create({ identifier: identifier as string }); + }; + + const handleSubmitResetPassword = async (formData: FormData) => { + const password = formData.get('password'); + if (!password) { + return; + } + + await signIn.resetPasswordEmailCode.submitPassword({ + password: password as string, + }); + + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: async () => { + navigate('/protected'); + }, + }); + } + }; + + const handleVerify = async (formData: FormData) => { + const code = formData.get('code') as string; + const password = formData.get('password') as string; + + if (selectedStrategy === 'email_code') { + await signIn.emailCode.verifyCode({ code: code }); + } else if (selectedStrategy === 'phone_code') { + await signIn.phoneCode.verifyCode({ code: code }); + } else if (selectedStrategy === 'password') { + await signIn.password({ password: password }); + } else if (selectedStrategy === 'reset_password_email_code') { + await signIn.resetPasswordEmailCode.verifyCode({ + code: code, + }); + } + + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: async () => { + navigate('/protected'); + }, + }); + } + }; + + const handleStrategyChange = async (strategy: AvailableStrategy) => { + if (strategy === 'email_code') { + // TODO @revamp-hooks: Allow calling sendCode without an argument + await signIn.emailCode.sendCode({}); + } else if (strategy === 'phone_code') { + await signIn.phoneCode.sendCode({}); + } else if (strategy === 'reset_password_email_code') { + await signIn.resetPasswordEmailCode.sendCode(); + } + + setSelectedStrategy(strategy); + }; + + if (signIn.status === 'needs_first_factor' && !selectedStrategy) { + return ( +
+ + + Choose a sign in method + + +
+ {signIn.availableStrategies + .filter(({ strategy }) => strategy !== 'reset_password_email_code') + .map(({ strategy }) => ( + + ))} +
+
+
+
+ ); + } + + if (signIn.status === 'needs_first_factor' && selectedStrategy === 'password') { + return ( +
+ + + Sign in with password + Enter your password below + + +
+
+
+ + + {errors.fields.password &&
{errors.fields.password.message}
} +
+ + +
+
+
+
+
+ ); + } + + if ( + signIn.status === 'needs_first_factor' && + (selectedStrategy === 'email_code' || + selectedStrategy === 'phone_code' || + selectedStrategy === 'reset_password_email_code') + ) { + return ( +
+ + + Sign in with code + Enter the code sent to your phone number or email below + + +
+
+
+ + + {errors.fields.code &&
{errors.fields.code.message}
} +
+ +
+
+
+
+
+ ); + } + + if (signIn.status === 'needs_new_password') { + return ( +
+ + + Set new password + + +
+
+
+
+ + +
+ +
+
+
+
+
+
+ ); + } + + // Prevent showing the sign-in form if the sign-in is complete. + if (signIn.status === 'complete' || isSignedIn) { + return null; + } + + return ( +
+ + + Sign in + + +
+
+ +
+
+ + +
+ +
+
+ Don't have an account?{' '} + + Sign up + +
+
+
+
+
+
+ ); +} diff --git a/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx b/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx new file mode 100644 index 00000000000..ef74268e35a --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useSignUpSignal } from '@clerk/clerk-react/experimental'; +import { NavLink, useNavigate } from 'react-router'; + +export function SignUp({ className, ...props }: React.ComponentProps<'div'>) { + const { signUp, errors, fetchStatus } = useSignUpSignal(); + const navigate = useNavigate(); + + const handleSubmit = async (formData: FormData) => { + const username = formData.get('username') as string | null; + const emailAddress = formData.get('emailAddress') as string | null; + const phoneNumber = formData.get('phoneNumber') as string | null; + const password = formData.get('password') as string | null; + + if (!emailAddress || !password) { + return; + } + + if (phoneNumber) { + await signUp.password({ phoneNumber, password }); + } else { + await signUp.password({ emailAddress, password }); + } + + if (signUp.status === 'missing_requirements') { + if (signUp.unverifiedFields.includes('email_address')) { + await signUp.verifications.sendEmailCode({ emailAddress }); + } else if (signUp.unverifiedFields.includes('phone_number')) { + await signUp.verifications.sendPhoneCode({ phoneNumber }); + } + } + }; + + const handleVerify = async (formData: FormData) => { + const code = formData.get('code') as string | null; + + if (!code) { + return; + } + + if (signUp.unverifiedFields.includes('email_address')) { + await signUp.verifications.verifyEmailCode({ code }); + } else if (signUp.unverifiedFields.includes('phone_number')) { + await signUp.verifications.verifyPhoneCode({ code }); + } + + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: async () => { + navigate('/protected'); + }, + }); + } + }; + + if ( + signUp.status === 'missing_requirements' && + (signUp.unverifiedFields.includes('email_address') || signUp.unverifiedFields.includes('phone_number')) + ) { + return ( +
+ + + Sign up with code + Enter the code sent to your email or phone number below + + +
+
+
+ + + {errors.fields.code &&
{errors.fields.code.message}
} +
+ +
+
+
+
+
+ ); + } + + return ( +
+ + + Sign up + Enter your email or phone number below to create an account + + +
+
+
+
+ + + {errors.fields.username && ( +

{errors.fields.username.longMessage}

+ )} +
+
+ + + {errors.fields.emailAddress && ( +

{errors.fields.emailAddress.longMessage}

+ )} +
+
+ + + {errors.fields.phoneNumber && ( +

{errors.fields.phoneNumber.longMessage}

+ )} +
+
+ + + {errors.fields.password && ( +

{errors.fields.password.longMessage}

+ )} +
+ + {errors.global && ( +

{(errors.global[0] as { longMessage: string }).longMessage}

+ )} +
+
+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+
+
+ ); +} diff --git a/integration/templates/custom-flows-react-vite/src/vite-env.d.ts b/integration/templates/custom-flows-react-vite/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/integration/templates/custom-flows-react-vite/tsconfig.app.json b/integration/templates/custom-flows-react-vite/tsconfig.app.json new file mode 100644 index 00000000000..d362bf629d7 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/integration/templates/custom-flows-react-vite/tsconfig.json b/integration/templates/custom-flows-react-vite/tsconfig.json new file mode 100644 index 00000000000..2b78387c740 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/tsconfig.json @@ -0,0 +1,10 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/integration/templates/custom-flows-react-vite/tsconfig.node.json b/integration/templates/custom-flows-react-vite/tsconfig.node.json new file mode 100644 index 00000000000..f85a39906e5 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/integration/templates/custom-flows-react-vite/vite.config.ts b/integration/templates/custom-flows-react-vite/vite.config.ts new file mode 100644 index 00000000000..22f2fb044c3 --- /dev/null +++ b/integration/templates/custom-flows-react-vite/vite.config.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/integration/templates/index.ts b/integration/templates/index.ts index ea75de29bc9..5c117a1f523 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -19,6 +19,7 @@ export const templates = { 'nuxt-node': resolve(__dirname, './nuxt-node'), 'react-router-node': resolve(__dirname, './react-router-node'), 'react-router-library': resolve(__dirname, './react-router-library'), + 'custom-flows-react-vite': resolve(__dirname, './custom-flows-react-vite'), } as const; if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) { diff --git a/integration/tests/custom-flows/sign-in.test.ts b/integration/tests/custom-flows/sign-in.test.ts new file mode 100644 index 00000000000..bc14d123e36 --- /dev/null +++ b/integration/tests/custom-flows/sign-in.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from '@playwright/test'; +import { parsePublishableKey } from '@clerk/shared/keys'; +import { clerkSetup } from '@clerk/testing/playwright'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { createTestUtils, FakeUser } from '../../testUtils'; + +test.describe('Custom Flows Sign In @custom', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + test.setTimeout(150_000); + app = await appConfigs.customFlows.reactVite.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL'); + const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey); + + await clerkSetup({ + publishableKey, + frontendApiUrl, + secretKey, + // @ts-expect-error + apiUrl, + dotenv: false, + }); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('can sign in with email code', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible(); + + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.page.getByRole('button', { name: 'email_code', exact: true }).click(); + await u.page.getByRole('textbox', { name: 'code' }).fill('424242'); + await u.po.signIn.continue(); + await u.page.waitForURL(/protected/); + await u.po.expect.toBeSignedIn(); + }); + + test('renders error with invalid email code', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible(); + + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.page.getByRole('button', { name: 'email_code', exact: true }).click(); + await u.page.getByRole('textbox', { name: 'code' }).fill('000000'); + await u.po.signIn.continue(); + await expect(u.page.getByText('is incorrect')).toBeVisible(); + }); + + test('can sign in with phone code', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible(); + + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.page.getByRole('button', { name: 'phone_code', exact: true }).click(); + await u.page.getByRole('textbox', { name: 'code' }).fill('424242'); + await u.po.signIn.continue(); + await u.page.waitForURL(/protected/); + await u.po.expect.toBeSignedIn(); + }); + + test('can sign in with password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible(); + + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.continue(); + await u.page.getByRole('button', { name: 'password', exact: true }).click(); + await u.page.getByRole('textbox', { name: 'password' }).fill(fakeUser.password); + await u.po.signIn.continue(); + await u.page.waitForURL(/protected/); + await u.po.expect.toBeSignedIn(); + }); +}); diff --git a/integration/tests/custom-flows/sign-up.test.ts b/integration/tests/custom-flows/sign-up.test.ts new file mode 100644 index 00000000000..55e224121dd --- /dev/null +++ b/integration/tests/custom-flows/sign-up.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; +import { parsePublishableKey } from '@clerk/shared/keys'; +import { clerkSetup } from '@clerk/testing/playwright'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import { createTestUtils, FakeUser } from '../../testUtils'; + +test.describe('Custom Flows Sign Up @custom', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + test.setTimeout(150_000); + app = await appConfigs.customFlows.reactVite.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY'); + const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL'); + const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey); + + await clerkSetup({ + publishableKey, + frontendApiUrl, + secretKey, + // @ts-expect-error + apiUrl, + dotenv: false, + }); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPassword: true, + withPhoneNumber: true, + withUsername: true, + }); + }); + + test.afterEach(async () => { + await fakeUser.deleteIfExists(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test('can sign up with email and password', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-up'); + await expect(u.page.getByText('Sign up', { exact: true })).toBeVisible(); + + await u.po.signUp.signUp({ email: fakeUser.email, password: fakeUser.password }); + // wait for the prepare call to complete + await u.page.waitForResponse( + response => + response.request().method() === 'POST' && + (response.url().includes('prepare_verification') || response.url().includes('prepare_first_factor')), + ); + await u.page.getByRole('textbox', { name: 'code' }).fill('424242'); + await u.po.signUp.continue(); + await u.page.waitForURL(/protected/); + await u.po.expect.toBeSignedIn(); + }); +}); diff --git a/package.json b/package.json index 4390a3e067f..f24ea5fddd1 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:integration:base": "pnpm playwright test --config integration/playwright.config.ts", "test:integration:billing": "E2E_APP_ID=withBilling.* pnpm test:integration:base --grep @billing", "test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts", + "test:integration:custom": "pnpm test:integration:base --grep @custom", "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", "test:integration:elements": "E2E_APP_ID=elements.* pnpm test:integration:base --grep @elements", "test:integration:expo-web": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web", diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 9d2f150b451..cb18c479600 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -68,22 +68,31 @@ export class StateProxy implements State { } private buildSignUpProxy() { + const gateProperty = this.gateProperty.bind(this); + const gateMethod = this.gateMethod.bind(this); + const wrapMethods = this.wrapMethods.bind(this); const target = () => this.client.signUp.__internal_future; return { errors: defaultErrors(), fetchStatus: 'idle' as const, signUp: { - status: 'missing_requirements' as const, - unverifiedFields: [], - isTransferable: false, - - create: this.gateMethod(target, 'create'), - sso: this.gateMethod(target, 'sso'), - password: this.gateMethod(target, 'password'), - finalize: this.gateMethod(target, 'finalize'), - - verifications: this.wrapMethods(() => target().verifications, [ + get status() { + return gateProperty(target, 'status', 'missing_requirements'); + }, + get unverifiedFields() { + return gateProperty(target, 'unverifiedFields', []); + }, + get isTransferable() { + return gateProperty(target, 'isTransferable', false); + }, + + create: gateMethod(target, 'create'), + sso: gateMethod(target, 'sso'), + password: gateMethod(target, 'password'), + finalize: gateMethod(target, 'finalize'), + + verifications: wrapMethods(() => target().verifications, [ 'sendEmailCode', 'verifyEmailCode', 'sendPhoneCode', @@ -106,6 +115,16 @@ export class StateProxy implements State { return c; } + private gateProperty(getTarget: () => T, key: K, defaultValue: T[K]) { + return (() => { + if (!inBrowser() || !this.isomorphicClerk.loaded) { + return defaultValue; + } + const t = getTarget(); + return t[key]; + })() as T[K]; + } + private gateMethod(getTarget: () => T, key: K) { type F = Extract unknown>; return (async (...args: Parameters): Promise> => { diff --git a/turbo.json b/turbo.json index 46fba8aadaf..2a410faebd0 100644 --- a/turbo.json +++ b/turbo.json @@ -356,6 +356,17 @@ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], "inputs": ["integration/**"], "outputLogs": "new-only" + }, + "//#test:integration:custom": { + "dependsOn": [ + "@clerk/testing#build", + "@clerk/clerk-js#build", + "@clerk/backend#build", + "@clerk/clerk-react#build" + ], + "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"], + "inputs": ["integration/**"], + "outputLogs": "new-only" } } }