Skip to content

feat: support guest session #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/(auth)/api/auth/guest/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Consider adding a rate limit for guest sign-ins to prevent abuse of the anonymous user creation feature.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement: Add error handling for the case where signIn('guest') fails. Currently, if sign-in fails, the code will still try to redirect.

redirect('/');
}

return new Response('Unauthorized', { status: 401 });
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic Issue: This response will never be reached because of the redirect above. If the user is not authenticated, they'll be redirected, and if they are authenticated, this endpoint shouldn't return an error.

}
28 changes: 1 addition & 27 deletions app/(auth)/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
24 changes: 18 additions & 6 deletions app/(auth)/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement: Consider destructuring the user object directly from the result of getUser(email) for cleaner code: const [user = null] = 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;
},
}),
],
Expand Down
4 changes: 4 additions & 0 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Import: The useSession hook is imported but not used at the module level. This should be moved inside the component function.


export default function Page() {
const router = useRouter();
Expand All @@ -23,6 +24,8 @@ export default function Page() {
},
);

const { update: updateSession } = useSession();

useEffect(() => {
if (state.status === 'failed') {
toast({
Expand All @@ -36,6 +39,7 @@ export default function Page() {
});
} else if (state.status === 'success') {
setIsSuccessful(true);
updateSession();
router.refresh();
}
}, [state.status]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency Warning: You're missing router and possibly toast in the dependency array of this useEffect.

Expand Down
4 changes: 4 additions & 0 deletions app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Import: The useSession hook is imported but not used at the module level. This should be moved inside the component function.


export default function Page() {
const router = useRouter();
Expand All @@ -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!' });
Expand All @@ -37,6 +40,7 @@ export default function Page() {
toast({ type: 'success', description: 'Account created successfully!' });

setIsSuccessful(true);
updateSession();
router.refresh();
}
}, [state]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency Warning: You're missing router in the dependency array of this useEffect.

Expand Down
34 changes: 31 additions & 3 deletions app/(chat)/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
UIMessage,
type UIMessage,
appendResponseMessages,
createDataStreamResponse,
smoothStream,
Expand All @@ -10,6 +10,7 @@ import { systemPrompt } from '@/lib/ai/prompts';
import {
deleteChatById,
getChatById,
getMessageCountByUserId,
saveChat,
saveMessages,
} from '@/lib/db/queries';
Expand All @@ -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;

Expand All @@ -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(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement: Consider extracting the membership tier determination logic to a separate function for better reusability.

session.user.email ?? '',
)
? 'guest'
: 'free';

const messageCount = await getMessageCountByUserId({
id: session.user.id,
differenceInHours: 24,
});

if (
messageCount >
entitlementsByMembershipTier[membershipTier].maxMessagesPerDay
) {
return new Response(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX Suggestion: Consider providing more detailed error information in the 429 response, such as when the user can try again or how many messages they've used.

'You have exceeded your maximum number of messages for the day',
{
status: 429,
},
);
}

const userMessage = getMostRecentUserMessage(messages);

if (!userMessage) {
Expand Down
3 changes: 2 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -77,7 +78,7 @@ export default async function RootLayout({
disableTransitionOnChange
>
<Toaster position="top-center" />
{children}
<SessionProvider>{children}</SessionProvider>
</ThemeProvider>
</body>
</html>
Expand Down
71 changes: 55 additions & 16 deletions components/sidebar-user-nav.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span className="truncate">{user?.email}</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
{status === 'loading' ? (
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10 justify-between">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: The loading state could be optimized by using a skeleton component instead of animating multiple elements.

<div className="flex flex-row gap-2">
<div className="size-6 bg-zinc-500/30 rounded-full animate-pulse" />
<span className="bg-zinc-500/30 text-transparent rounded-md animate-pulse">
Loading auth status
</span>
</div>
<div className="animate-spin text-zinc-500">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessibility: Consider adding an aria-label to the loading spinner for better accessibility.

<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton className="data-[state=open]:bg-sidebar-accent bg-background data-[state=open]:text-sidebar-accent-foreground h-10">
<Image
src={`https://avatar.vercel.sh/${user.email}`}
alt={user.email ?? 'User Avatar'}
width={24}
height={24}
className="rounded-full"
/>
<span className="truncate">
{isGuest ? 'Guest' : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
Expand All @@ -54,12 +79,26 @@ export function SidebarUserNav({ user }: { user: User }) {
type="button"
className="w-full cursor-pointer"
onClick={() => {
signOut({
redirectTo: '/',
});
if (status === 'loading') {
toast({
type: 'error',
description:
'Checking authentication status, please try again!',
});

return;
}

if (isGuest) {
router.push('/login');
} else {
signOut({
redirectTo: '/',
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dependency Warning: You should include isGuest and possibly status in the dependency array of the onClick callback to ensure it always has the latest values.

}
}}
>
Sign out
{isGuest ? 'Login to your account' : 'Sign out'}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
Expand Down
33 changes: 33 additions & 0 deletions lib/ai/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ChatModel } from './models';

export type MembershipTier = 'guest' | 'free';

interface Entitlements {
maxMessagesPerDay: number;
chatModelsAvailable: Array<ChatModel['id']>;
}

export const entitlementsByMembershipTier: Record<
MembershipTier,
Entitlements
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation: Consider adding JSDoc comments to explain the purpose and constraints of each membership tier.

> = {
/*
* 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
*/
};
2 changes: 1 addition & 1 deletion lib/ai/models.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const DEFAULT_CHAT_MODEL: string = 'chat-model';

interface ChatModel {
export interface ChatModel {
id: string;
name: string;
description: string;
Expand Down
2 changes: 2 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const isTestEnvironment = Boolean(
process.env.PLAYWRIGHT ||
process.env.CI_PLAYWRIGHT,
);

export const anonymousRegex = /^anonymous-\d+$/;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: The anonymous regex pattern might be too simple. Consider a more robust pattern to ensure it only matches the expected format.

Loading