diff --git a/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 0000000000..ab890d4da8 --- /dev/null +++ b/app/(auth)/api/auth/guest/route.ts @@ -0,0 +1,13 @@ +import { redirect } from 'next/navigation'; +import { auth, signIn } from '@/app/(auth)/auth'; + +export async function GET() { + const session = await auth(); + + if (!session?.user?.id) { + await signIn('guest', { redirect: false }); + redirect('/'); + } + + return new Response('Unauthorized', { status: 401 }); +} diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts index cf1ecdd89e..b7d7d50bf1 100644 --- a/app/(auth)/auth.config.ts +++ b/app/(auth)/auth.config.ts @@ -9,31 +9,5 @@ export const authConfig = { // added later in auth.ts since it requires bcrypt which is only compatible with Node.js // while this file is also used in non-Node.js environments ], - callbacks: { - authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user; - const isOnChat = nextUrl.pathname.startsWith('/'); - const isOnRegister = nextUrl.pathname.startsWith('/register'); - const isOnLogin = nextUrl.pathname.startsWith('/login'); - - if (isLoggedIn && (isOnLogin || isOnRegister)) { - return Response.redirect(new URL('/', nextUrl as unknown as URL)); - } - - if (isOnRegister || isOnLogin) { - return true; // Always allow access to register and login pages - } - - if (isOnChat) { - if (isLoggedIn) return true; - return false; // Redirect unauthenticated users to login page - } - - if (isLoggedIn) { - return Response.redirect(new URL('/', nextUrl as unknown as URL)); - } - - return true; - }, - }, + callbacks: {}, } satisfies NextAuthConfig; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index d8a436988a..ea59d6fad9 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -2,7 +2,7 @@ import { compare } from 'bcrypt-ts'; import NextAuth, { type User, type Session } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; -import { getUser } from '@/lib/db/queries'; +import { createAnonymousUser, getUser } from '@/lib/db/queries'; import { authConfig } from './auth.config'; @@ -21,12 +21,24 @@ export const { Credentials({ credentials: {}, async authorize({ email, password }: any) { - const users = await getUser(email); - if (users.length === 0) return null; - // biome-ignore lint: Forbidden non-null assertion. - const passwordsMatch = await compare(password, users[0].password!); + const [user] = await getUser(email); + + if (!user) return null; + if (!user.password) return null; + + const passwordsMatch = await compare(password, user.password); + if (!passwordsMatch) return null; - return users[0] as any; + + return user; + }, + }), + Credentials({ + id: 'guest', + credentials: {}, + async authorize() { + const [anonymousUser] = await createAnonymousUser(); + return anonymousUser; }, }), ], diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index ba987c280d..33e9e82709 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -9,6 +9,7 @@ import { AuthForm } from '@/components/auth-form'; import { SubmitButton } from '@/components/submit-button'; import { login, type LoginActionState } from '../actions'; +import { useSession } from 'next-auth/react'; export default function Page() { const router = useRouter(); @@ -23,6 +24,8 @@ export default function Page() { }, ); + const { update: updateSession } = useSession(); + useEffect(() => { if (state.status === 'failed') { toast({ @@ -36,6 +39,7 @@ export default function Page() { }); } else if (state.status === 'success') { setIsSuccessful(true); + updateSession(); router.refresh(); } }, [state.status]); diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 649278e24f..ab2ee82667 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -9,6 +9,7 @@ import { SubmitButton } from '@/components/submit-button'; import { register, type RegisterActionState } from '../actions'; import { toast } from '@/components/toast'; +import { useSession } from 'next-auth/react'; export default function Page() { const router = useRouter(); @@ -23,6 +24,8 @@ export default function Page() { }, ); + const { update: updateSession } = useSession(); + useEffect(() => { if (state.status === 'user_exists') { toast({ type: 'error', description: 'Account already exists!' }); @@ -37,6 +40,7 @@ export default function Page() { toast({ type: 'success', description: 'Account created successfully!' }); setIsSuccessful(true); + updateSession(); router.refresh(); } }, [state]); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index e574d7dc50..b1a9a535f8 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,5 +1,5 @@ import { - UIMessage, + type UIMessage, appendResponseMessages, createDataStreamResponse, smoothStream, @@ -10,6 +10,7 @@ import { systemPrompt } from '@/lib/ai/prompts'; import { deleteChatById, getChatById, + getMessageCountByUserId, saveChat, saveMessages, } from '@/lib/db/queries'; @@ -23,8 +24,12 @@ import { createDocument } from '@/lib/ai/tools/create-document'; import { updateDocument } from '@/lib/ai/tools/update-document'; import { requestSuggestions } from '@/lib/ai/tools/request-suggestions'; import { getWeather } from '@/lib/ai/tools/get-weather'; -import { isProductionEnvironment } from '@/lib/constants'; +import { anonymousRegex, isProductionEnvironment } from '@/lib/constants'; import { myProvider } from '@/lib/ai/providers'; +import { + entitlementsByMembershipTier, + type MembershipTier, +} from '@/lib/ai/capabilities'; export const maxDuration = 60; @@ -42,10 +47,33 @@ export async function POST(request: Request) { const session = await auth(); - if (!session || !session.user || !session.user.id) { + if (!session?.user?.id) { return new Response('Unauthorized', { status: 401 }); } + const membershipTier: MembershipTier = anonymousRegex.test( + session.user.email ?? '', + ) + ? 'guest' + : 'free'; + + const messageCount = await getMessageCountByUserId({ + id: session.user.id, + differenceInHours: 24, + }); + + if ( + messageCount > + entitlementsByMembershipTier[membershipTier].maxMessagesPerDay + ) { + return new Response( + 'You have exceeded your maximum number of messages for the day', + { + status: 429, + }, + ); + } + const userMessage = getMostRecentUserMessage(messages); if (!userMessage) { diff --git a/app/layout.tsx b/app/layout.tsx index 8a6ad5d448..1813e0f71b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google'; import { ThemeProvider } from '@/components/theme-provider'; import './globals.css'; +import { SessionProvider } from 'next-auth/react'; export const metadata: Metadata = { metadataBase: new URL('https://chat.vercel.ai'), @@ -77,7 +78,7 @@ export default async function RootLayout({ disableTransitionOnChange > - {children} + {children} diff --git a/components/sidebar-user-nav.tsx b/components/sidebar-user-nav.tsx index cb41ec76c6..6289ecf09f 100644 --- a/components/sidebar-user-nav.tsx +++ b/components/sidebar-user-nav.tsx @@ -1,8 +1,9 @@ 'use client'; + import { ChevronUp } from 'lucide-react'; import Image from 'next/image'; import type { User } from 'next-auth'; -import { signOut } from 'next-auth/react'; +import { signOut, useSession } from 'next-auth/react'; import { useTheme } from 'next-themes'; import { @@ -17,26 +18,50 @@ import { SidebarMenuButton, SidebarMenuItem, } from '@/components/ui/sidebar'; +import { anonymousRegex } from '@/lib/constants'; +import { useRouter } from 'next/navigation'; +import { toast } from './toast'; +import { LoaderIcon } from './icons'; export function SidebarUserNav({ user }: { user: User }) { + const router = useRouter(); + const { data, status } = useSession(); const { setTheme, theme } = useTheme(); + const isGuest = anonymousRegex.test(data?.user?.email ?? ''); + return ( - - {user.email - {user?.email} - - + {status === 'loading' ? ( + +
+
+ + Loading auth status + +
+
+ +
+ + ) : ( + + {user.email + + {isGuest ? 'Guest' : user?.email} + + + + )} { - signOut({ - redirectTo: '/', - }); + if (status === 'loading') { + toast({ + type: 'error', + description: + 'Checking authentication status, please try again!', + }); + + return; + } + + if (isGuest) { + router.push('/login'); + } else { + signOut({ + redirectTo: '/', + }); + } }} > - Sign out + {isGuest ? 'Login to your account' : 'Sign out'} diff --git a/lib/ai/capabilities.ts b/lib/ai/capabilities.ts new file mode 100644 index 0000000000..914d4d9f98 --- /dev/null +++ b/lib/ai/capabilities.ts @@ -0,0 +1,33 @@ +import type { ChatModel } from './models'; + +export type MembershipTier = 'guest' | 'free'; + +interface Entitlements { + maxMessagesPerDay: number; + chatModelsAvailable: Array; +} + +export const entitlementsByMembershipTier: Record< + MembershipTier, + Entitlements +> = { + /* + * For users without an account + */ + guest: { + maxMessagesPerDay: 20, + chatModelsAvailable: ['chat-model', 'chat-model-reasoning'], + }, + + /* + * For user with an account + */ + free: { + maxMessagesPerDay: 100, + chatModelsAvailable: ['chat-model', 'chat-model-reasoning'], + }, + + /* + * TODO: For users with an account and a paid membership + */ +}; diff --git a/lib/ai/models.ts b/lib/ai/models.ts index ab3e471d63..91fb137ed4 100644 --- a/lib/ai/models.ts +++ b/lib/ai/models.ts @@ -1,6 +1,6 @@ export const DEFAULT_CHAT_MODEL: string = 'chat-model'; -interface ChatModel { +export interface ChatModel { id: string; name: string; description: string; diff --git a/lib/constants.ts b/lib/constants.ts index 6c27325a5a..5b26195a46 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -5,3 +5,5 @@ export const isTestEnvironment = Boolean( process.env.PLAYWRIGHT || process.env.CI_PLAYWRIGHT, ); + +export const anonymousRegex = /^anonymous-\d+$/; diff --git a/lib/db/queries.ts b/lib/db/queries.ts index d51c5ae207..16ca360eba 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,7 +1,18 @@ import 'server-only'; import { genSaltSync, hashSync } from 'bcrypt-ts'; -import { and, asc, desc, eq, gt, gte, inArray, lt, SQL } from 'drizzle-orm'; +import { + and, + asc, + count, + desc, + eq, + gt, + gte, + inArray, + lt, + type SQL, +} from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @@ -15,9 +26,10 @@ import { message, vote, type DBMessage, - Chat, + type Chat, } from './schema'; -import { ArtifactKind } from '@/components/artifact'; +import type { ArtifactKind } from '@/components/artifact'; +import { generateUUID } from '../utils'; // Optionally, if not using email/pass login, you can // use the Drizzle adapter for Auth.js / NextAuth @@ -48,6 +60,22 @@ export async function createUser(email: string, password: string) { } } +export async function createAnonymousUser() { + const salt = genSaltSync(10); + const hash = hashSync(generateUUID(), salt); + const email = `anonymous-${Date.now()}`; + + try { + return await db.insert(user).values({ email, password: hash }).returning({ + id: user.id, + email: user.email, + }); + } catch (error) { + console.error('Failed to create anonymous user in database'); + throw error; + } +} + export async function saveChat({ id, userId, @@ -405,3 +433,30 @@ export async function updateChatVisiblityById({ throw error; } } + +export async function getMessageCountByUserId({ + id, + differenceInHours, +}: { id: string; differenceInHours: number }) { + try { + const twentyFourHoursAgo = new Date( + Date.now() - differenceInHours * 60 * 60 * 1000, + ); + + const [stats] = await db + .select({ count: count(message.id) }) + .from(message) + .innerJoin(chat, eq(message.chatId, chat.id)) + .where( + and(eq(chat.userId, id), gte(message.createdAt, twentyFourHoursAgo)), + ) + .execute(); + + return stats?.count ?? 0; + } catch (error) { + console.error( + 'Failed to get message count by user id for the last 24 hours from database', + ); + throw error; + } +} diff --git a/middleware.ts b/middleware.ts index 1dd8458f47..bf5d7c8ebe 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,9 +1,49 @@ -import NextAuth from 'next-auth'; +import { auth } from './app/(auth)/auth'; +import { NextResponse, type NextRequest } from 'next/server'; +import { anonymousRegex } from './lib/constants'; -import { authConfig } from '@/app/(auth)/auth.config'; +export async function middleware(request: NextRequest) { + // Skip the check for the guest auth endpoint to avoid infinite loops. + if (request.nextUrl.pathname.startsWith('/api/auth/guest')) { + return NextResponse.next(); + } -export default NextAuth(authConfig).auth; + const session = await auth(); + + // If no session exists, rewrite the URL to the guest endpoint. + if (!session) { + return NextResponse.redirect(new URL('/api/auth/guest', request.url)); + } + + const isLoggedIn = session.user; + const isAnonymousUser = anonymousRegex.test(session.user?.email ?? ''); + + const isOnLoginPage = request.nextUrl.pathname.startsWith('/login'); + const isOnRegisterPage = request.nextUrl.pathname.startsWith('/register'); + + // If the user is logged in and not an anonymous user, redirect to the home page + if (isLoggedIn && !isAnonymousUser && (isOnLoginPage || isOnRegisterPage)) { + return NextResponse.redirect(new URL('/', request.url)); + } + + // Otherwise, continue handling the request. + return NextResponse.next(); +} export const config = { - matcher: ['/', '/:id', '/api/:path*', '/login', '/register'], + matcher: [ + '/', + '/chat/:id', + '/api/:path*', + '/login', + '/register', + + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico, sitemap.xml, robots.txt (metadata files) + */ + '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)', + ], };