From c770e817250f246aab1ae5d0870f1a7a2f004420 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 16:10:11 +0000 Subject: [PATCH 1/3] feat: persist invitation token across auth flows This fix addresses the issue where users lose their invitation token when they reset their password during the invitation acceptance flow. Changes: - Add invitation token persistence utility that stores the token in a httpOnly cookie when a user lands with ?invitation_token= in the URL - Add InvitationTokenCapture component in the root layout to automatically capture and store invitation tokens from URL parameters - Update all authentication methods (email/password, magic auth, OAuth, SSO) to check for and use stored invitation tokens - Token is automatically consumed (deleted) after being used in an authentication request This ensures that if a user: 1. Receives an invitation with invitation_token in URL 2. Clicks to accept but forgets their password 3. Resets their password 4. Signs in after password reset The invitation token will still be available to accept the invitation and properly set up their organization membership and custom JWT claims. Co-authored-by: Alex Southgate --- src/app/layout.tsx | 5 ++ .../basic/callback/route.ts | 5 ++ .../with-session/callback/route.ts | 5 ++ .../sign-in/email-password/email-password.ts | 5 ++ .../sign-in/github-oauth/callback/route.ts | 5 ++ .../sign-in/google-oauth/callback/route.ts | 5 ++ .../sign-in/magic-auth/magic-auth.ts | 5 ++ .../sign-in/microsoft-oauth/callback/route.ts | 5 ++ .../sign-in/sso/callback/route.ts | 5 ++ src/components/InvitationTokenCapture.tsx | 30 ++++++++++++ src/lib/invitation-token.ts | 49 +++++++++++++++++++ 11 files changed, 124 insertions(+) create mode 100644 src/components/InvitationTokenCapture.tsx create mode 100644 src/lib/invitation-token.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a678df0..6f01670 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; +import { Suspense } from 'react'; import './globals.css'; import BackLink from './back-link'; +import { InvitationTokenCapture } from '@/components/InvitationTokenCapture'; const inter = Inter({ subsets: ['latin'] }); @@ -15,6 +17,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( + + + {children} diff --git a/src/app/using-hosted-authkit/basic/callback/route.ts b/src/app/using-hosted-authkit/basic/callback/route.ts index 38ec436..4e9793a 100644 --- a/src/app/using-hosted-authkit/basic/callback/route.ts +++ b/src/app/using-hosted-authkit/basic/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-hosted-authkit/with-session/callback/route.ts b/src/app/using-hosted-authkit/with-session/callback/route.ts index 8fe57b1..d4fdc13 100644 --- a/src/app/using-hosted-authkit/with-session/callback/route.ts +++ b/src/app/using-hosted-authkit/with-session/callback/route.ts @@ -2,6 +2,7 @@ import { WorkOS } from '@workos-inc/node'; import { NextResponse } from 'next/server'; import { SignJWT } from 'jose'; import { getJwtSecretKey } from '../auth'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -15,10 +16,14 @@ export async function GET(request: Request) { const url = new URL(request.url); const code = url.searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + try { const { user } = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); // Create a JWT with the user's information diff --git a/src/app/using-your-own-ui/sign-in/email-password/email-password.ts b/src/app/using-your-own-ui/sign-in/email-password/email-password.ts index 337029c..b75f996 100644 --- a/src/app/using-your-own-ui/sign-in/email-password/email-password.ts +++ b/src/app/using-your-own-ui/sign-in/email-password/email-password.ts @@ -11,11 +11,15 @@ // to the client for security reasons. import { WorkOS } from '@workos-inc/node'; +import { consumeInvitationToken } from '@/lib/invitation-token'; const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function signIn(prevState: any, formData: FormData) { try { + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + // For the sake of simplicity, we directly return the user here. // In a real application, you would probably store the user in a token (JWT) // and store that token in your DB or use cookies. @@ -23,6 +27,7 @@ export async function signIn(prevState: any, formData: FormData) { clientId: process.env.WORKOS_CLIENT_ID || '', email: String(formData.get('email')), password: String(formData.get('password')), + invitationToken, }); } catch (error) { return { error: JSON.parse(JSON.stringify(error)) }; diff --git a/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts b/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts index 2741617..1ff5eca 100644 --- a/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/github-oauth/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts b/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts index 762ebc9..f300ce3 100644 --- a/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/google-oauth/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts b/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts index 98f5cd6..b106635 100644 --- a/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts +++ b/src/app/using-your-own-ui/sign-in/magic-auth/magic-auth.ts @@ -11,6 +11,7 @@ // to the client for security reasons. import { WorkOS } from '@workos-inc/node'; +import { consumeInvitationToken } from '@/lib/invitation-token'; const workos = new WorkOS(process.env.WORKOS_API_KEY); @@ -26,6 +27,9 @@ export async function sendCode(prevState: any, formData: FormData) { export async function signIn(prevState: any, formData: FormData) { try { + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + // For the sake of simplicity, we directly return the user here. // In a real application, you would probably store the user in a token (JWT) // and store that token in your DB or use cookies. @@ -33,6 +37,7 @@ export async function signIn(prevState: any, formData: FormData) { clientId: process.env.WORKOS_CLIENT_ID || '', code: String(formData.get('code')), email: String(formData.get('email')), + invitationToken, }); } catch (error) { return { error: JSON.parse(JSON.stringify(error)) }; diff --git a/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts b/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts index d65cbd2..ba0d48a 100644 --- a/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/microsoft-oauth/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/app/using-your-own-ui/sign-in/sso/callback/route.ts b/src/app/using-your-own-ui/sign-in/sso/callback/route.ts index 550e07a..43b4339 100644 --- a/src/app/using-your-own-ui/sign-in/sso/callback/route.ts +++ b/src/app/using-your-own-ui/sign-in/sso/callback/route.ts @@ -1,5 +1,6 @@ import { WorkOS } from '@workos-inc/node'; import { redirect } from 'next/navigation'; +import { consumeInvitationToken } from '@/lib/invitation-token'; // This is a Next.js Route Handler. // @@ -16,12 +17,16 @@ const workos = new WorkOS(process.env.WORKOS_API_KEY); export async function GET(request: Request) { const code = new URL(request.url).searchParams.get('code') || ''; + // Check for a stored invitation token (persisted across auth flows like password reset) + const invitationToken = await consumeInvitationToken(); + let response; try { response = await workos.userManagement.authenticateWithCode({ clientId: process.env.WORKOS_CLIENT_ID || '', code, + invitationToken, }); } catch (error) { response = error; diff --git a/src/components/InvitationTokenCapture.tsx b/src/components/InvitationTokenCapture.tsx new file mode 100644 index 0000000..1dd8769 --- /dev/null +++ b/src/components/InvitationTokenCapture.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { storeInvitationToken } from '@/lib/invitation-token'; + +/** + * Client component that captures invitation_token from URL parameters + * and stores it in a cookie for persistence across auth flows. + * + * This ensures that if a user: + * 1. Receives an invitation + * 2. Clicks to accept but forgets their password + * 3. Resets their password + * + * The invitation token will still be available after password reset + * to complete the invitation acceptance. + */ +export function InvitationTokenCapture() { + const searchParams = useSearchParams(); + + useEffect(() => { + const invitationToken = searchParams.get('invitation_token'); + if (invitationToken) { + storeInvitationToken(invitationToken); + } + }, [searchParams]); + + return null; +} diff --git a/src/lib/invitation-token.ts b/src/lib/invitation-token.ts new file mode 100644 index 0000000..2a94188 --- /dev/null +++ b/src/lib/invitation-token.ts @@ -0,0 +1,49 @@ +'use server'; + +import { cookies } from 'next/headers'; + +const INVITATION_TOKEN_COOKIE = 'workos_invitation_token'; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days (invitation tokens expire in 6 days) + +/** + * Store the invitation token in a cookie. + * This allows the token to persist across page navigations and auth flows + * (e.g., when a user starts an invitation flow but then resets their password). + */ +export async function storeInvitationToken(token: string): Promise { + cookies().set(INVITATION_TOKEN_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: COOKIE_MAX_AGE, + path: '/', + }); +} + +/** + * Retrieve the stored invitation token from the cookie. + * Returns undefined if no token is stored. + */ +export async function getStoredInvitationToken(): Promise { + return cookies().get(INVITATION_TOKEN_COOKIE)?.value; +} + +/** + * Clear the stored invitation token cookie. + * Should be called after the invitation has been accepted or is no longer needed. + */ +export async function clearInvitationToken(): Promise { + cookies().delete(INVITATION_TOKEN_COOKIE); +} + +/** + * Get and clear the invitation token in one operation. + * This is useful for consuming the token during authentication. + */ +export async function consumeInvitationToken(): Promise { + const token = await getStoredInvitationToken(); + if (token) { + await clearInvitationToken(); + } + return token; +} From 810797bac136f502db39409a74290c4b55646492 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Mar 2026 17:10:21 +0000 Subject: [PATCH 2/3] test: add tests for invitation token persistence - Add Jest testing framework setup - Add unit tests for invitation-token utility functions - Add tests for InvitationTokenCapture component - Add flow simulation tests verifying token persistence All 14 tests passing. Co-authored-by: Alex Southgate --- jest.config.js | 16 ++ jest.setup.js | 1 + package.json | 10 +- .../__tests__/InvitationTokenCapture.test.tsx | 64 ++++++++ src/lib/__tests__/invitation-token.test.ts | 152 ++++++++++++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 jest.config.js create mode 100644 jest.setup.js create mode 100644 src/components/__tests__/InvitationTokenCapture.test.tsx create mode 100644 src/lib/__tests__/invitation-token.test.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..d4fc386 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,16 @@ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ + dir: './', +}); + +const customJestConfig = { + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jest-environment-jsdom', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + testPathIgnorePatterns: ['/node_modules/', '/.next/'], +}; + +module.exports = createJestConfig(customJestConfig); diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/jest.setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/package.json b/package.json index 8523fbe..1b754f0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { "@workos-inc/authkit-nextjs": "0.4.2", @@ -17,11 +19,17 @@ "react-dom": "^18" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jest": "^30.0.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.1.4", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "ts-jest": "^29.4.6", "typescript": "^5" } } diff --git a/src/components/__tests__/InvitationTokenCapture.test.tsx b/src/components/__tests__/InvitationTokenCapture.test.tsx new file mode 100644 index 0000000..f996071 --- /dev/null +++ b/src/components/__tests__/InvitationTokenCapture.test.tsx @@ -0,0 +1,64 @@ +import { render, waitFor } from '@testing-library/react'; +import { InvitationTokenCapture } from '../InvitationTokenCapture'; + +const mockStoreInvitationToken = jest.fn(); + +jest.mock('@/lib/invitation-token', () => ({ + storeInvitationToken: (...args: any[]) => mockStoreInvitationToken(...args), +})); + +const mockSearchParams = new Map(); + +jest.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => mockSearchParams.get(key), + }), +})); + +describe('InvitationTokenCapture', () => { + beforeEach(() => { + mockSearchParams.clear(); + mockStoreInvitationToken.mockClear(); + }); + + it('should store invitation token when present in URL', async () => { + const token = 'test_invitation_token_xyz'; + mockSearchParams.set('invitation_token', token); + + render(); + + await waitFor(() => { + expect(mockStoreInvitationToken).toHaveBeenCalledWith(token); + }); + }); + + it('should not call storeInvitationToken when no token in URL', async () => { + render(); + + await waitFor(() => { + expect(mockStoreInvitationToken).not.toHaveBeenCalled(); + }); + }); + + it('should handle different token values', async () => { + const tokens = ['Z1uX3RbwcIl5fIGJJJCXXisdI', 'abc123', 'invitation_test_456']; + + for (const token of tokens) { + mockSearchParams.set('invitation_token', token); + mockStoreInvitationToken.mockClear(); + + const { unmount } = render(); + + await waitFor(() => { + expect(mockStoreInvitationToken).toHaveBeenCalledWith(token); + }); + + unmount(); + } + }); + + it('should render nothing (null)', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/lib/__tests__/invitation-token.test.ts b/src/lib/__tests__/invitation-token.test.ts new file mode 100644 index 0000000..5b85d6c --- /dev/null +++ b/src/lib/__tests__/invitation-token.test.ts @@ -0,0 +1,152 @@ +import { + storeInvitationToken, + getStoredInvitationToken, + clearInvitationToken, + consumeInvitationToken, +} from '../invitation-token'; + +const mockCookieStore = new Map(); + +const mockCookies = { + get: jest.fn((name: string) => mockCookieStore.get(name)), + set: jest.fn((name: string, value: string, options: any) => { + mockCookieStore.set(name, { value }); + }), + delete: jest.fn((name: string) => { + mockCookieStore.delete(name); + }), +}; + +jest.mock('next/headers', () => ({ + cookies: () => mockCookies, +})); + +describe('invitation-token', () => { + beforeEach(() => { + mockCookieStore.clear(); + jest.clearAllMocks(); + }); + + describe('storeInvitationToken', () => { + it('should store the invitation token in a cookie', async () => { + const token = 'test_invitation_token_123'; + await storeInvitationToken(token); + + expect(mockCookies.set).toHaveBeenCalledWith( + 'workos_invitation_token', + token, + expect.objectContaining({ + httpOnly: true, + sameSite: 'lax', + path: '/', + }) + ); + }); + + it('should set a 7-day expiration', async () => { + const token = 'test_token'; + await storeInvitationToken(token); + + expect(mockCookies.set).toHaveBeenCalledWith( + 'workos_invitation_token', + token, + expect.objectContaining({ + maxAge: 60 * 60 * 24 * 7, + }) + ); + }); + }); + + describe('getStoredInvitationToken', () => { + it('should return the stored token', async () => { + mockCookieStore.set('workos_invitation_token', { value: 'stored_token' }); + + const result = await getStoredInvitationToken(); + expect(result).toBe('stored_token'); + }); + + it('should return undefined if no token is stored', async () => { + const result = await getStoredInvitationToken(); + expect(result).toBeUndefined(); + }); + }); + + describe('clearInvitationToken', () => { + it('should delete the cookie', async () => { + mockCookieStore.set('workos_invitation_token', { value: 'to_delete' }); + + await clearInvitationToken(); + + expect(mockCookies.delete).toHaveBeenCalledWith('workos_invitation_token'); + }); + }); + + describe('consumeInvitationToken', () => { + it('should return the token and clear it', async () => { + mockCookieStore.set('workos_invitation_token', { value: 'consume_me' }); + + const result = await consumeInvitationToken(); + + expect(result).toBe('consume_me'); + expect(mockCookies.delete).toHaveBeenCalledWith('workos_invitation_token'); + }); + + it('should return undefined if no token exists', async () => { + const result = await consumeInvitationToken(); + + expect(result).toBeUndefined(); + expect(mockCookies.delete).not.toHaveBeenCalled(); + }); + }); +}); + +describe('invitation token flow simulation', () => { + beforeEach(() => { + mockCookieStore.clear(); + jest.clearAllMocks(); + }); + + it('should persist token through multiple page navigations', async () => { + const invitationToken = 'Z1uX3RbwcIl5fIGJJJCXXisdI'; + + // User lands on page with invitation_token + await storeInvitationToken(invitationToken); + + // Simulate navigation to password reset page + // Token should still be retrievable + const tokenAfterNavigation = await getStoredInvitationToken(); + expect(tokenAfterNavigation).toBe(invitationToken); + + // User completes password reset and signs in + // Token is consumed during authentication + const tokenForAuth = await consumeInvitationToken(); + expect(tokenForAuth).toBe(invitationToken); + + // Token should be cleared after consumption + const tokenAfterAuth = await getStoredInvitationToken(); + expect(tokenAfterAuth).toBeUndefined(); + }); + + it('should handle the case where user lands without invitation token', async () => { + // User lands on sign-in page without invitation token + const token = await getStoredInvitationToken(); + expect(token).toBeUndefined(); + + // Authentication should work without invitation token + const tokenForAuth = await consumeInvitationToken(); + expect(tokenForAuth).toBeUndefined(); + }); + + it('should overwrite existing token if user receives new invitation', async () => { + const firstToken = 'first_invitation_token'; + const secondToken = 'second_invitation_token'; + + // First invitation + await storeInvitationToken(firstToken); + expect(await getStoredInvitationToken()).toBe(firstToken); + + // Second invitation (overwrites first) + await storeInvitationToken(secondToken); + expect(await getStoredInvitationToken()).toBe(secondToken); + }); +}); From 39b6d88f91ac1065776eb72dabcff940db967991 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 4 Mar 2026 17:30:31 +0000 Subject: [PATCH 3/3] test: add API endpoint for testing invitation token persistence This endpoint allows testing the invitation token cookie flow: - Store, get, consume, and clear operations - Useful for verifying the fix works in development Note: Consider removing or protecting this endpoint in production. Co-authored-by: Alex Southgate --- src/app/api/test-invitation-token/route.ts | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/app/api/test-invitation-token/route.ts diff --git a/src/app/api/test-invitation-token/route.ts b/src/app/api/test-invitation-token/route.ts new file mode 100644 index 0000000..ee32880 --- /dev/null +++ b/src/app/api/test-invitation-token/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + storeInvitationToken, + getStoredInvitationToken, + consumeInvitationToken, + clearInvitationToken, +} from '@/lib/invitation-token'; + +export async function GET(request: NextRequest) { + const action = request.nextUrl.searchParams.get('action'); + const token = request.nextUrl.searchParams.get('token'); + + try { + switch (action) { + case 'store': + if (!token) { + return NextResponse.json({ error: 'Token required for store action' }, { status: 400 }); + } + await storeInvitationToken(token); + return NextResponse.json({ success: true, action: 'stored', token }); + + case 'get': + const storedToken = await getStoredInvitationToken(); + return NextResponse.json({ success: true, action: 'get', token: storedToken || null }); + + case 'consume': + const consumedToken = await consumeInvitationToken(); + return NextResponse.json({ success: true, action: 'consumed', token: consumedToken || null }); + + case 'clear': + await clearInvitationToken(); + return NextResponse.json({ success: true, action: 'cleared' }); + + default: + return NextResponse.json({ + error: 'Invalid action. Use: store, get, consume, or clear', + usage: { + store: '/api/test-invitation-token?action=store&token=YOUR_TOKEN', + get: '/api/test-invitation-token?action=get', + consume: '/api/test-invitation-token?action=consume', + clear: '/api/test-invitation-token?action=clear', + }, + }, { status: 400 }); + } + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +}