Skip to content

Commit 3583cb2

Browse files
feat: support guest session
1 parent ccbc649 commit 3583cb2

File tree

11 files changed

+260
-47
lines changed

11 files changed

+260
-47
lines changed

app/(auth)/api/auth/guest/route.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { redirect } from 'next/navigation';
2+
import { auth, signIn } from '@/app/(auth)/auth';
3+
4+
export async function GET() {
5+
const session = await auth();
6+
7+
if (!session?.user?.id) {
8+
await signIn('guest', { redirect: false });
9+
redirect('/');
10+
}
11+
12+
return new Response('Unauthorized', { status: 401 });
13+
}

app/(auth)/auth.config.ts

+14-15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { anonymousRegex } from '@/lib/constants';
12
import type { NextAuthConfig } from 'next-auth';
23

34
export const authConfig = {
@@ -12,25 +13,23 @@ export const authConfig = {
1213
callbacks: {
1314
authorized({ auth, request: { nextUrl } }) {
1415
const isLoggedIn = !!auth?.user;
15-
const isOnChat = nextUrl.pathname.startsWith('/');
16-
const isOnRegister = nextUrl.pathname.startsWith('/register');
17-
const isOnLogin = nextUrl.pathname.startsWith('/login');
16+
const isAnonymousUser = anonymousRegex.test(auth?.user?.email ?? '');
1817

19-
if (isLoggedIn && (isOnLogin || isOnRegister)) {
20-
return Response.redirect(new URL('/', nextUrl as unknown as URL));
21-
}
22-
23-
if (isOnRegister || isOnLogin) {
24-
return true; // Always allow access to register and login pages
25-
}
18+
const isOnLoginPage = nextUrl.pathname.startsWith('/login');
19+
const isOnRegisterPage = nextUrl.pathname.startsWith('/register');
2620

27-
if (isOnChat) {
28-
if (isLoggedIn) return true;
29-
return false; // Redirect unauthenticated users to login page
21+
// If logged in, redirect to home page
22+
if (
23+
isLoggedIn &&
24+
!isAnonymousUser &&
25+
(isOnLoginPage || isOnRegisterPage)
26+
) {
27+
return Response.redirect(new URL('/', nextUrl as unknown as URL));
3028
}
3129

32-
if (isLoggedIn) {
33-
return Response.redirect(new URL('/', nextUrl as unknown as URL));
30+
// Always allow access to register and login pages
31+
if (isOnRegisterPage || isOnLoginPage) {
32+
return true;
3433
}
3534

3635
return true;

app/(auth)/auth.ts

+17-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { compare } from 'bcrypt-ts';
22
import NextAuth, { type User, type Session } from 'next-auth';
33
import Credentials from 'next-auth/providers/credentials';
44

5-
import { getUser } from '@/lib/db/queries';
5+
import { createAnonymousUser, getUser } from '@/lib/db/queries';
66

77
import { authConfig } from './auth.config';
88

@@ -21,12 +21,23 @@ export const {
2121
Credentials({
2222
credentials: {},
2323
async authorize({ email, password }: any) {
24-
const users = await getUser(email);
25-
if (users.length === 0) return null;
26-
// biome-ignore lint: Forbidden non-null assertion.
27-
const passwordsMatch = await compare(password, users[0].password!);
24+
const [user] = await getUser(email);
25+
26+
if (!user) return null;
27+
if (!user.password) return null;
28+
29+
const passwordsMatch = await compare(password, user.password);
30+
2831
if (!passwordsMatch) return null;
29-
return users[0] as any;
32+
return user;
33+
},
34+
}),
35+
Credentials({
36+
id: 'guest',
37+
credentials: {},
38+
async authorize() {
39+
const [anonymousUser] = await createAnonymousUser();
40+
return anonymousUser;
3041
},
3142
}),
3243
],

app/(chat)/api/chat/route.ts

+31-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
UIMessage,
2+
type UIMessage,
33
appendResponseMessages,
44
createDataStreamResponse,
55
smoothStream,
@@ -10,6 +10,7 @@ import { systemPrompt } from '@/lib/ai/prompts';
1010
import {
1111
deleteChatById,
1212
getChatById,
13+
getMessageCountByUserId,
1314
saveChat,
1415
saveMessages,
1516
} from '@/lib/db/queries';
@@ -23,8 +24,12 @@ import { createDocument } from '@/lib/ai/tools/create-document';
2324
import { updateDocument } from '@/lib/ai/tools/update-document';
2425
import { requestSuggestions } from '@/lib/ai/tools/request-suggestions';
2526
import { getWeather } from '@/lib/ai/tools/get-weather';
26-
import { isProductionEnvironment } from '@/lib/constants';
27+
import { anonymousRegex, isProductionEnvironment } from '@/lib/constants';
2728
import { myProvider } from '@/lib/ai/providers';
29+
import {
30+
entitlementsByMembershipTier,
31+
type MembershipTier,
32+
} from '@/lib/ai/capabilities';
2833

2934
export const maxDuration = 60;
3035

@@ -42,10 +47,33 @@ export async function POST(request: Request) {
4247

4348
const session = await auth();
4449

45-
if (!session || !session.user || !session.user.id) {
50+
if (!session?.user?.id) {
4651
return new Response('Unauthorized', { status: 401 });
4752
}
4853

54+
const membershipTier: MembershipTier = anonymousRegex.test(
55+
session.user.email ?? '',
56+
)
57+
? 'guest'
58+
: 'free';
59+
60+
const messageCount = await getMessageCountByUserId({
61+
id: session.user.id,
62+
differenceInHours: 24,
63+
});
64+
65+
if (
66+
messageCount >
67+
entitlementsByMembershipTier[membershipTier].maxMessagesPerDay
68+
) {
69+
return new Response(
70+
'You have exceeded your maximum number of messages for the day',
71+
{
72+
status: 429,
73+
},
74+
);
75+
}
76+
4977
const userMessage = getMostRecentUserMessage(messages);
5078

5179
if (!userMessage) {

app/layout.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google';
44
import { ThemeProvider } from '@/components/theme-provider';
55

66
import './globals.css';
7+
import { SessionProvider } from 'next-auth/react';
78

89
export const metadata: Metadata = {
910
metadataBase: new URL('https://chat.vercel.ai'),
@@ -77,7 +78,7 @@ export default async function RootLayout({
7778
disableTransitionOnChange
7879
>
7980
<Toaster position="top-center" />
80-
{children}
81+
<SessionProvider>{children}</SessionProvider>
8182
</ThemeProvider>
8283
</body>
8384
</html>

components/sidebar-user-nav.tsx

+55-16
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
2+
23
import { ChevronUp } from 'lucide-react';
34
import Image from 'next/image';
45
import type { User } from 'next-auth';
5-
import { signOut } from 'next-auth/react';
6+
import { signOut, useSession } from 'next-auth/react';
67
import { useTheme } from 'next-themes';
78

89
import {
@@ -17,26 +18,50 @@ import {
1718
SidebarMenuButton,
1819
SidebarMenuItem,
1920
} from '@/components/ui/sidebar';
21+
import { anonymousRegex } from '@/lib/constants';
22+
import { useRouter } from 'next/navigation';
23+
import { toast } from './toast';
24+
import { LoaderIcon } from './icons';
2025

2126
export function SidebarUserNav({ user }: { user: User }) {
27+
const router = useRouter();
28+
const { data, status } = useSession();
2229
const { setTheme, theme } = useTheme();
2330

31+
const isGuest = anonymousRegex.test(data?.user?.email ?? '');
32+
2433
return (
2534
<SidebarMenu>
2635
<SidebarMenuItem>
2736
<DropdownMenu>
2837
<DropdownMenuTrigger asChild>
29-
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
30-
<Image
31-
src={`https://avatar.vercel.sh/${user.email}`}
32-
alt={user.email ?? 'User Avatar'}
33-
width={24}
34-
height={24}
35-
className="rounded-full"
36-
/>
37-
<span className="truncate">{user?.email}</span>
38-
<ChevronUp className="ml-auto" />
39-
</SidebarMenuButton>
38+
{status === 'loading' ? (
39+
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10 justify-between">
40+
<div className="flex flex-row gap-2">
41+
<div className="size-6 bg-zinc-500/30 rounded-full animate-pulse" />
42+
<span className="bg-zinc-500/30 text-transparent rounded-md animate-pulse">
43+
Loading auth status
44+
</span>
45+
</div>
46+
<div className="animate-spin text-zinc-500/30">
47+
<LoaderIcon />
48+
</div>
49+
</SidebarMenuButton>
50+
) : (
51+
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
52+
<Image
53+
src={`https://avatar.vercel.sh/${user.email}`}
54+
alt={user.email ?? 'User Avatar'}
55+
width={24}
56+
height={24}
57+
className="rounded-full"
58+
/>
59+
<span className="truncate">
60+
{isGuest ? 'Guest' : user?.email}
61+
</span>
62+
<ChevronUp className="ml-auto" />
63+
</SidebarMenuButton>
64+
)}
4065
</DropdownMenuTrigger>
4166
<DropdownMenuContent
4267
side="top"
@@ -54,12 +79,26 @@ export function SidebarUserNav({ user }: { user: User }) {
5479
type="button"
5580
className="w-full cursor-pointer"
5681
onClick={() => {
57-
signOut({
58-
redirectTo: '/',
59-
});
82+
if (status === 'loading') {
83+
toast({
84+
type: 'error',
85+
description:
86+
'Checking authentication status, please try again!',
87+
});
88+
89+
return;
90+
}
91+
92+
if (isGuest) {
93+
router.push('/login');
94+
} else {
95+
signOut({
96+
redirectTo: '/',
97+
});
98+
}
6099
}}
61100
>
62-
Sign out
101+
{isGuest ? 'Login to your account' : 'Sign out'}
63102
</button>
64103
</DropdownMenuItem>
65104
</DropdownMenuContent>

lib/ai/capabilities.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ChatModel } from './models';
2+
3+
export type MembershipTier = 'guest' | 'free';
4+
5+
interface Entitlements {
6+
maxMessagesPerDay: number;
7+
chatModelsAvailable: Array<ChatModel['id']>;
8+
}
9+
10+
export const entitlementsByMembershipTier: Record<
11+
MembershipTier,
12+
Entitlements
13+
> = {
14+
/*
15+
* For users without an account
16+
*/
17+
guest: {
18+
maxMessagesPerDay: 20,
19+
chatModelsAvailable: ['chat-model', 'chat-model-reasoning'],
20+
},
21+
22+
/*
23+
* For user with an account
24+
*/
25+
free: {
26+
maxMessagesPerDay: 100,
27+
chatModelsAvailable: ['chat-model', 'chat-model-reasoning'],
28+
},
29+
30+
/*
31+
* TODO: For users with an account and a paid membership
32+
*/
33+
};

lib/ai/models.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export const DEFAULT_CHAT_MODEL: string = 'chat-model';
22

3-
interface ChatModel {
3+
export interface ChatModel {
44
id: string;
55
name: string;
66
description: string;

lib/constants.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ export const isTestEnvironment = Boolean(
55
process.env.PLAYWRIGHT ||
66
process.env.CI_PLAYWRIGHT,
77
);
8+
9+
export const anonymousRegex = /^anonymous-\d+$/;

0 commit comments

Comments
 (0)