feat: support guest session (#919)

This commit is contained in:
Jeremy 2025-04-25 23:40:15 -07:00 committed by GitHub
parent 24cb2ce19b
commit 9279135355
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 741 additions and 288 deletions

View file

@ -12,17 +12,20 @@ import { useSidebar } from './ui/sidebar';
import { memo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { type VisibilityType, VisibilitySelector } from './visibility-selector';
import type { Session } from 'next-auth';
function PureChatHeader({
chatId,
selectedModelId,
selectedVisibilityType,
isReadonly,
session,
}: {
chatId: string;
selectedModelId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
session: Session;
}) {
const router = useRouter();
const { open } = useSidebar();
@ -54,6 +57,7 @@ function PureChatHeader({
{!isReadonly && (
<ModelSelector
session={session}
selectedModelId={selectedModelId}
className="order-1 md:order-2"
/>

View file

@ -12,9 +12,10 @@ import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages';
import type { VisibilityType } from './visibility-selector';
import { useArtifactSelector } from '@/hooks/use-artifact';
import { toast } from 'sonner';
import { unstable_serialize } from 'swr/infinite';
import { getChatHistoryPaginationKey } from './sidebar-history';
import { toast } from './toast';
import type { Session } from 'next-auth';
export function Chat({
id,
@ -22,12 +23,14 @@ export function Chat({
selectedChatModel,
selectedVisibilityType,
isReadonly,
session,
}: {
id: string;
initialMessages: Array<UIMessage>;
selectedChatModel: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
session: Session;
}) {
const { mutate } = useSWRConfig();
@ -51,8 +54,11 @@ export function Chat({
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
},
onError: () => {
toast.error('An error occurred, please try again!');
onError: (error) => {
toast({
type: 'error',
description: error.message,
});
},
});
@ -72,6 +78,7 @@ export function Chat({
selectedModelId={selectedChatModel}
selectedVisibilityType={selectedVisibilityType}
isReadonly={isReadonly}
session={session}
/>
<Messages

View file

@ -14,20 +14,34 @@ import { chatModels } from '@/lib/ai/models';
import { cn } from '@/lib/utils';
import { CheckCircleFillIcon, ChevronDownIcon } from './icons';
import { entitlementsByUserType } from '@/lib/ai/entitlements';
import type { Session } from 'next-auth';
export function ModelSelector({
session,
selectedModelId,
className,
}: {
session: Session;
selectedModelId: string;
} & React.ComponentProps<typeof Button>) {
const [open, setOpen] = useState(false);
const [optimisticModelId, setOptimisticModelId] =
useOptimistic(selectedModelId);
const userType = session.user.type;
const { availableChatModelIds } = entitlementsByUserType[userType];
const availableChatModels = chatModels.filter((chatModel) =>
availableChatModelIds.includes(chatModel.id),
);
const selectedChatModel = useMemo(
() => chatModels.find((chatModel) => chatModel.id === optimisticModelId),
[optimisticModelId],
() =>
availableChatModels.find(
(chatModel) => chatModel.id === optimisticModelId,
),
[optimisticModelId, availableChatModels],
);
return (
@ -49,7 +63,7 @@ export function ModelSelector({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]">
{chatModels.map((chatModel) => {
{availableChatModels.map((chatModel) => {
const { id } = chatModel;
return (

View file

@ -19,6 +19,7 @@ export function SidebarToggle({
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid="sidebar-toggle-button"
onClick={toggleSidebar}
variant="outline"
className="md:px-2 md:h-fit"

View file

@ -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,49 +18,92 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useRouter } from 'next/navigation';
import { toast } from './toast';
import { LoaderIcon } from './icons';
import { guestRegex } from '@/lib/constants';
export function SidebarUserNav({ user }: { user: User }) {
const router = useRouter();
const { data, status } = useSession();
const { setTheme, theme } = useTheme();
const isGuest = guestRegex.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">
<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">
<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton
data-testid="user-nav-button"
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 data-testid="user-email" className="truncate">
{isGuest ? 'Guest' : user?.email}
</span>
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
data-testid="user-nav-menu"
side="top"
className="w-[--radix-popper-anchor-width]"
>
<DropdownMenuItem
data-testid="user-nav-item-theme"
className="cursor-pointer"
onSelect={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{`Toggle ${theme === 'light' ? 'dark' : 'light'} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
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: '/',
});
}
}}
>
Sign out
{isGuest ? 'Login to your account' : 'Sign out'}
</button>
</DropdownMenuItem>
</DropdownMenuContent>

View file

@ -1,8 +1,9 @@
'use client';
import React, { ReactNode } from 'react';
import React, { useEffect, useRef, useState, type ReactNode } from 'react';
import { toast as sonnerToast } from 'sonner';
import { CheckCircleFillIcon, WarningIcon } from './icons';
import { cn } from '@/lib/utils';
const iconsByType: Record<'success' | 'error', ReactNode> = {
success: <CheckCircleFillIcon />,
@ -18,20 +19,48 @@ export function toast(props: Omit<ToastProps, 'id'>) {
function Toast(props: ToastProps) {
const { id, type, description } = props;
const descriptionRef = useRef<HTMLDivElement>(null);
const [multiLine, setMultiLine] = useState(false);
useEffect(() => {
const el = descriptionRef.current;
if (!el) return;
const update = () => {
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
const lines = Math.round(el.scrollHeight / lineHeight);
setMultiLine(lines > 1);
};
update(); // initial check
const ro = new ResizeObserver(update); // re-check on width changes
ro.observe(el);
return () => ro.disconnect();
}, [description]);
return (
<div className="flex w-full toast-mobile:w-[356px] justify-center">
<div
data-testid="toast"
key={id}
className="bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-2 items-center"
className={cn(
'bg-zinc-100 p-3 rounded-lg w-full toast-mobile:w-fit flex flex-row gap-3',
multiLine ? 'items-start' : 'items-center',
)}
>
<div
data-type={type}
className="data-[type=error]:text-red-600 data-[type=success]:text-green-600"
className={cn(
'data-[type=error]:text-red-600 data-[type=success]:text-green-600',
{ 'pt-1': multiLine },
)}
>
{iconsByType[type]}
</div>
<div className="text-zinc-950 text-sm">{description}</div>
<div ref={descriptionRef} className="text-zinc-950 text-sm">
{description}
</div>
</div>
</div>
);