diff --git a/.cursorignore b/.cursorignore index fe443571f90..9ee59b13859 100644 --- a/.cursorignore +++ b/.cursorignore @@ -5,6 +5,7 @@ # Environment files .env .env.* +!.env.example # Secrets directory secrets/ diff --git a/packages/swingset/.env.example b/packages/swingset/.env.example new file mode 100644 index 00000000000..6af4e643fe1 --- /dev/null +++ b/packages/swingset/.env.example @@ -0,0 +1,6 @@ +# Copy to packages/swingset/.env.local and restart Swingset (`pnpm dev:swingset`). +# Required for /live, /sign-in, and /sign-up. The component explorer works without it. +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= + +# Optional. Enables the Clerk handshake for SSO / OAuth on /sign-in and /sign-up. +CLERK_SECRET_KEY= diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index a9c62bc3fb7..9eff37ea125 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -1,12 +1,14 @@ import createMDX from '@next/mdx'; import stylexPlugin from '@stylexjs/unplugin/webpack'; -import { resolve } from 'path'; +import { createRequire } from 'module'; +import { dirname, resolve } from 'path'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import { fileURLToPath } from 'url'; import { mosaicLightningCssTargets } from '../ui/stylex-lightningcss.config.mjs'; +const require = createRequire(import.meta.url); const __dirname = fileURLToPath(new URL('.', import.meta.url)); const withMDX = createMDX({ @@ -98,6 +100,9 @@ const nextConfig = { config.resolve.alias['@clerk/headless/hooks'] = resolve(__dirname, '../headless/src/hooks'); config.resolve.alias['@clerk/headless/utils'] = resolve(__dirname, '../headless/src/utils'); config.resolve.alias['@clerk/headless'] = resolve(__dirname, '../headless/src/primitives'); + // Mosaic/headless source imports this. Webpack resolves from the aliased file's + // directory, which is outside swingset, so the package has to be named here. + config.resolve.alias['@floating-ui/react'] = dirname(require.resolve('@floating-ui/react/package.json')); return config; }, }; diff --git a/packages/swingset/package.json b/packages/swingset/package.json index bce696a74bc..d1bc3f9274f 100644 --- a/packages/swingset/package.json +++ b/packages/swingset/package.json @@ -12,7 +12,10 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@clerk/headless": "workspace:*", + "@clerk/nextjs": "workspace:*", + "@clerk/shared": "workspace:*", "@clerk/ui": "workspace:*", + "@floating-ui/react": "catalog:repo", "@stylexjs/stylex": "0.19.0", "@tailwindcss/typography": "^0.5.19", "class-variance-authority": "^0.7.1", diff --git a/packages/swingset/src/app/(clerk)/layout.tsx b/packages/swingset/src/app/(clerk)/layout.tsx new file mode 100644 index 00000000000..7cfd477d711 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/layout.tsx @@ -0,0 +1,63 @@ +import { ClerkProvider } from '@clerk/nextjs'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +import { LiveUserButton } from './live-user-button'; + +function LiveChrome({ children, userButton }: { children: ReactNode; userButton?: ReactNode }) { + return ( +
+
+
+ + ← Swingset + + Live +
+ {userButton} +
+ {children} +
+ ); +} + +export default function ClerkLayout({ children }: { children: ReactNode }) { + const publishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + + if (!publishableKey) { + return ( + +
+

Live

+

+ To access sign-in, sign-up, and live pages, set up a publishable key. +

+

+ Copy packages/swingset/.env.example to{' '} + packages/swingset/.env.local and set{' '} + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, then restart Swingset. +

+

+ CLERK_SECRET_KEY is optional. Add it for SSO and OAuth redirects. +

+
+
+ ); + } + + return ( + + }>{children} + + ); +} diff --git a/packages/swingset/src/app/(clerk)/live-user-button.tsx b/packages/swingset/src/app/(clerk)/live-user-button.tsx new file mode 100644 index 00000000000..a9cd463181b --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live-user-button.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import { UserButton } from '@clerk/ui/mosaic/user-button/user-button'; + +export function LiveUserButton() { + return ( + + + + ); +} diff --git a/packages/swingset/src/app/(clerk)/live/reverification/page.tsx b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx new file mode 100644 index 00000000000..8ac70fa3643 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/reverification/page.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { SignOutButton, useUser } from '@clerk/nextjs'; +import { isReverificationCancelledError } from '@clerk/shared/error'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Reverification, useReverificationWithState } from '@clerk/ui/mosaic/features/reverification'; +import { MosaicProvider } from '@clerk/ui/mosaic/MosaicProvider'; +import Link from 'next/link'; +import { useState } from 'react'; + +async function mockDelete() { + const response = await fetch('/api/live/mock-delete', { method: 'POST' }); + const body = await response.json().catch(() => ({})); + + if (body?.clerk_error?.reason === 'reverification-error') { + return body; + } + if (!response.ok) { + throw new Error(typeof body.error === 'string' ? body.error : `Mock delete failed (${response.status})`); + } + return body; +} + +async function resetMockDelete() { + await fetch('/api/live/mock-delete', { method: 'DELETE' }); +} + +function DeleteAccountHarness() { + const { user } = useUser(); + const [status, setStatus] = useState<'idle' | 'success' | 'cancelled' | 'error'>('idle'); + const [message, setMessage] = useState(null); + const [deleteAccount, reverification] = useReverificationWithState(mockDelete); + + if (!user) { + return null; + } + + return ( +
+

Signed in as {user.primaryEmailAddress?.emailAddress ?? user.id}

+
+ + + + +
+ + {message ?

{message}

: null} +
+ ); +} + +// Throwaway live harness. Not a story. +export default function ReverificationLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Reverification

+

+ Throwaway live page. Delete account hits a mock route that returns a reverification hint, then a fake + success. The account is not deleted. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/sign-in/[[...sign-in]]/page.tsx b/packages/swingset/src/app/(clerk)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 00000000000..f6e768aa179 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { SignIn } from '@clerk/nextjs'; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/sign-up/[[...sign-up]]/page.tsx b/packages/swingset/src/app/(clerk)/sign-up/[[...sign-up]]/page.tsx new file mode 100644 index 00000000000..231bc8bd358 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/sign-up/[[...sign-up]]/page.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { SignUp } from '@clerk/nextjs'; + +export default function SignUpPage() { + return ( +
+ +
+ ); +} diff --git a/packages/swingset/src/app/[group]/[component]/page.tsx b/packages/swingset/src/app/(explorer)/[group]/[component]/page.tsx similarity index 100% rename from packages/swingset/src/app/[group]/[component]/page.tsx rename to packages/swingset/src/app/(explorer)/[group]/[component]/page.tsx diff --git a/packages/swingset/src/app/(explorer)/layout.tsx b/packages/swingset/src/app/(explorer)/layout.tsx new file mode 100644 index 00000000000..449351b6cec --- /dev/null +++ b/packages/swingset/src/app/(explorer)/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react'; + +import { ClientRoot } from '@/components/ClientRoot'; + +export default function ExplorerLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/packages/swingset/src/app/page.tsx b/packages/swingset/src/app/(explorer)/page.tsx similarity index 100% rename from packages/swingset/src/app/page.tsx rename to packages/swingset/src/app/(explorer)/page.tsx diff --git a/packages/swingset/src/app/api/live/mock-delete/route.ts b/packages/swingset/src/app/api/live/mock-delete/route.ts new file mode 100644 index 00000000000..fb0b9d7fd8c --- /dev/null +++ b/packages/swingset/src/app/api/live/mock-delete/route.ts @@ -0,0 +1,23 @@ +import { reverificationErrorResponse } from '@clerk/nextjs/server'; +import { cookies } from 'next/headers'; + +const COOKIE = 'swingset-mock-delete'; + +export async function POST() { + const store = await cookies(); + + // First call: ask for step-up. After the card completes, the same fetcher is retried. + if (store.get(COOKIE)?.value !== '1') { + store.set(COOKIE, '1', { path: '/', maxAge: 120, sameSite: 'lax' }); + return reverificationErrorResponse('strict'); + } + + store.delete(COOKIE); + return Response.json({ ok: true, at: new Date().toISOString() }); +} + +export async function DELETE() { + const store = await cookies(); + store.delete(COOKIE); + return new Response(null, { status: 204 }); +} diff --git a/packages/swingset/src/app/layout.tsx b/packages/swingset/src/app/layout.tsx index 09ae389ad1c..33de3af1482 100644 --- a/packages/swingset/src/app/layout.tsx +++ b/packages/swingset/src/app/layout.tsx @@ -5,7 +5,6 @@ import { Geist } from 'next/font/google'; import Script from 'next/script'; import type React from 'react'; -import { ClientRoot } from '@/components/ClientRoot'; import { ThemeProvider } from '@/components/ThemeProvider'; import { cn } from '@/lib/utils'; @@ -32,9 +31,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) )} - - {children} - + {children} ); diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index c8a9cbcf053..066035dd1e0 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -9,6 +9,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component import { Sidebar, SidebarContent, + SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, @@ -231,6 +232,27 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ))} + + + + + Live + + + + + } + > + Reverification + + + + + + ); diff --git a/packages/swingset/src/middleware.ts b/packages/swingset/src/middleware.ts new file mode 100644 index 00000000000..c4f559e021d --- /dev/null +++ b/packages/swingset/src/middleware.ts @@ -0,0 +1,13 @@ +import { clerkMiddleware } from '@clerk/nextjs/server'; +import type { NextFetchEvent, NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +const withClerk = process.env.CLERK_SECRET_KEY ? clerkMiddleware() : null; + +export default function middleware(request: NextRequest, event: NextFetchEvent) { + return withClerk ? withClerk(request, event) : NextResponse.next(); +} + +export const config = { + matcher: ['/sign-in(.*)', '/sign-up(.*)', '/live(.*)'], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14c47378629..9ae4b7ed011 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1055,9 +1055,18 @@ importers: '@clerk/headless': specifier: workspace:* version: link:../headless + '@clerk/nextjs': + specifier: workspace:* + version: link:../nextjs + '@clerk/shared': + specifier: workspace:* + version: link:../shared '@clerk/ui': specifier: workspace:* version: link:../ui + '@floating-ui/react': + specifier: catalog:repo + version: 0.27.20(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@stylexjs/stylex': specifier: 0.19.0 version: 0.19.0