Merge pull request #24 from manmohan659/feat/e2e-integration

feat(frontend): wire frontend to real backend auth + chat-api
This commit is contained in:
Manmohan 2026-04-16 16:23:05 -04:00 committed by GitHub
commit bfa34a8a0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 608 additions and 369 deletions

32
.env.production.example Normal file
View File

@ -0,0 +1,32 @@
# Database (docker-compose postgres)
DATABASE_URL=postgresql+asyncpg://samosachaat_admin:CHANGE_ME@postgres:5432/samosachaat
POSTGRES_PASSWORD=CHANGE_ME
# Auth service
AUTH_BASE_URL=https://samosachaat.art/api
FRONTEND_URL=https://samosachaat.art
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
JWT_PRIVATE_KEY=
JWT_PUBLIC_KEY=
INTERNAL_API_KEY=CHANGE_ME_RANDOM_STRING
SESSION_SECRET=CHANGE_ME_RANDOM_STRING
COOKIE_SECURE=true
COOKIE_DOMAIN=samosachaat.art
# Chat API
AUTH_SERVICE_URL=http://auth:8001
INFERENCE_SERVICE_URL=http://inference:8003
CHAT_API_URL=http://chat-api:8002
# Inference
MODEL_STORAGE_PATH=/models
DEFAULT_MODEL_TAG=samosachaat-d12
NANOCHAT_DTYPE=float32
HF_TOKEN=
NUM_WORKERS=1
# Frontend
NEXT_PUBLIC_APP_URL=https://samosachaat.art

15
scripts/generate-jwt-keys.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
# Generate RS256 JWT key pair for samosaChaat auth service
set -e
openssl genrsa -out jwt_private.pem 2048
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
echo ""
echo "Keys generated: jwt_private.pem, jwt_public.pem"
echo ""
echo "Add to your .env file:"
echo "JWT_PRIVATE_KEY=\"$(cat jwt_private.pem | tr '\n' '|')\""
echo "JWT_PUBLIC_KEY=\"$(cat jwt_public.pem | tr '\n' '|')\""
echo ""
echo "The | characters will be replaced back to newlines by the services."

View File

@ -1,3 +0,0 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

View File

@ -8,6 +8,7 @@ interface StreamBody {
model?: string;
temperature?: number;
topK?: number;
conversationId?: string;
}
const encoder = new TextEncoder();
@ -16,10 +17,13 @@ function sseEvent(data: Record<string, unknown>) {
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
}
async function proxyUpstream(body: StreamBody, upstreamUrl: string) {
async function proxyUpstream(body: StreamBody, upstreamUrl: string, authHeader: string | null) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authHeader) headers['Authorization'] = authHeader;
const upstream = await fetch(upstreamUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({
messages: body.messages,
temperature: body.temperature ?? 0.8,
@ -83,9 +87,25 @@ export async function POST(req: NextRequest) {
}
const upstream = process.env.CHAT_API_URL;
const authHeader = req.headers.get('authorization');
if (upstream) {
try {
return await proxyUpstream(body, `${upstream.replace(/\/$/, '')}/chat/completions`);
// If we have a conversationId and auth, use the persisted messages endpoint
const convId = body.conversationId;
if (convId && authHeader) {
return await proxyUpstream(
body,
`${upstream.replace(/\/$/, '')}/api/conversations/${convId}/messages`,
authHeader,
);
}
// Fallback to direct chat completions (no persistence)
return await proxyUpstream(
body,
`${upstream.replace(/\/$/, '')}/chat/completions`,
authHeader,
);
} catch (err) {
console.warn('[chat/stream] upstream failed, falling back to mock:', err);
}

View File

@ -0,0 +1,40 @@
import { NextRequest } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const CHAT_API = process.env.CHAT_API_URL || 'http://chat-api:8002';
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const auth = req.headers.get('authorization');
if (!auth) return new Response('Unauthorized', { status: 401 });
const body = await req.json();
try {
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}/messages`, {
method: 'POST',
headers: {
Authorization: auth,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok || !res.body) {
return new Response(`Backend error: ${res.status}`, { status: res.status });
}
// Stream SSE through
return new Response(res.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
} catch (err) {
console.error('[conversations/:id/messages] POST error:', err);
return new Response('Internal Server Error', { status: 500 });
}
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
const CHAT_API = process.env.CHAT_API_URL || 'http://chat-api:8002';
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
const auth = req.headers.get('authorization');
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}`, {
headers: { Authorization: auth },
});
return NextResponse.json(await res.json(), { status: res.status });
} catch (err) {
console.error('[conversations/:id] GET error:', err);
return NextResponse.json({ error: 'Failed to fetch conversation' }, { status: 500 });
}
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
const auth = req.headers.get('authorization');
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
try {
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}`, {
method: 'PUT',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await res.json(), { status: res.status });
} catch (err) {
console.error('[conversations/:id] PUT error:', err);
return NextResponse.json({ error: 'Failed to update conversation' }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const auth = req.headers.get('authorization');
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const res = await fetch(`${CHAT_API}/api/conversations/${params.id}`, {
method: 'DELETE',
headers: { Authorization: auth },
});
return NextResponse.json({ ok: true }, { status: res.status });
} catch (err) {
console.error('[conversations/:id] DELETE error:', err);
return NextResponse.json({ error: 'Failed to delete conversation' }, { status: 500 });
}
}

View File

@ -1,33 +1,44 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
const day = 1000 * 60 * 60 * 24;
const now = () => Date.now();
const CHAT_API = process.env.CHAT_API_URL || 'http://chat-api:8002';
export async function GET() {
return NextResponse.json({
conversations: [
{
id: 'mock-1',
title: 'Why is samosa triangular?',
updatedAt: now() - 1000 * 60 * 30,
},
{
id: 'mock-2',
title: 'Chai masala recipe',
updatedAt: now() - day,
},
{
id: 'mock-3',
title: 'Explain transformers simply',
updatedAt: now() - day * 4,
},
{
id: 'mock-4',
title: 'Monsoon pakora tips',
updatedAt: now() - day * 15,
},
],
});
function getAuthHeader(req: NextRequest): string | null {
return req.headers.get('authorization');
}
export async function GET(req: NextRequest) {
const auth = getAuthHeader(req);
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const res = await fetch(`${CHAT_API}/api/conversations`, {
headers: { Authorization: auth, 'Content-Type': 'application/json' },
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
console.error('[conversations] proxy error:', err);
return NextResponse.json({ conversations: [] });
}
}
export async function POST(req: NextRequest) {
const auth = getAuthHeader(req);
if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
try {
const res = await fetch(`${CHAT_API}/api/conversations`, {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch (err) {
console.error('[conversations] create error:', err);
return NextResponse.json({ error: 'Failed to create conversation' }, { status: 500 });
}
}

View File

@ -1,9 +1,23 @@
'use client';
import { Suspense } from 'react';
import { useAuth, useTokenCapture } from '@/hooks/useAuth';
import Sidebar from '@/components/chat/Sidebar';
import ChatWindow from '@/components/chat/ChatWindow';
import { redirect } from 'next/navigation';
export const metadata = { title: 'Chat — samosaChaat' };
function ChatContent() {
useTokenCapture();
const { authenticated, loading } = useAuth();
if (loading) {
return <div className="flex h-dvh items-center justify-center">Loading...</div>;
}
if (!authenticated) {
redirect('/login');
return null;
}
export default function ChatPage() {
return (
<main className="flex h-dvh overflow-hidden">
<Sidebar />
@ -11,3 +25,11 @@ export default function ChatPage() {
</main>
);
}
export default function ChatPage() {
return (
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
<ChatContent />
</Suspense>
);
}

View File

@ -1,6 +1,5 @@
import type { Metadata, Viewport } from 'next';
import { Baloo_2, Great_Vibes, Caveat, Inter } from 'next/font/google';
import SessionBoundary from '@/components/SessionBoundary';
import './globals.css';
const baloo = Baloo_2({
@ -47,7 +46,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" className={`${baloo.variable} ${vibes.variable} ${caveat.variable} ${inter.variable}`}>
<body className="min-h-dvh bg-white text-gray-900">
<SessionBoundary>{children}</SessionBoundary>
{children}
</body>
</html>
);

View File

@ -1,16 +1,22 @@
'use client';
import Link from 'next/link';
import { Suspense } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
import MandalaArt from '@/components/login/MandalaArt';
import OAuthButtons from '@/components/login/OAuthButtons';
import SamosaLogo from '@/components/svg/SamosaLogo';
export const metadata = { title: 'Sign in — samosaChaat' };
function LoginContent() {
const { authenticated, loading } = useAuth();
if (loading) return null;
if (authenticated) {
redirect('/chat');
return null;
}
export default async function LoginPage() {
const session = await auth();
if (session?.user) redirect('/chat');
return (
<main className="min-h-dvh grid grid-cols-1 lg:grid-cols-2 bg-white">
<div className="hidden lg:block lg:min-h-dvh">
@ -30,9 +36,7 @@ export default async function LoginPage() {
</p>
<div className="mt-8">
<Suspense fallback={<div className="h-24" aria-hidden="true" />}>
<OAuthButtons />
</Suspense>
<OAuthButtons />
</div>
<div className="flex items-center gap-3 my-6" aria-hidden="true">
@ -76,3 +80,11 @@ export default async function LoginPage() {
</main>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<div className="flex h-dvh items-center justify-center">Loading...</div>}>
<LoginContent />
</Suspense>
);
}

View File

@ -1,36 +1,4 @@
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
secret: process.env.NEXTAUTH_SECRET,
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
],
pages: {
signIn: '/login',
},
session: { strategy: 'jwt' },
callbacks: {
async jwt({ token, account, profile }) {
if (account) {
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
if (session.user && token.sub) {
(session.user as { id?: string }).id = token.sub;
}
return session;
},
},
});
// NextAuth has been removed.
// Auth is now handled by the backend auth service (OAuth + JWT).
// See lib/auth-client.ts and hooks/useAuth.ts for the client-side token helpers.
export {};

View File

@ -1,7 +1,12 @@
'use client';
import Link from 'next/link';
import ToranSvg from './svg/ToranSvg';
import { useAuth } from '@/hooks/useAuth';
export default function LandingNav() {
const { authenticated } = useAuth();
return (
<nav className="relative flex justify-between items-start px-4 md:px-9 pt-4 pb-2 z-10 flex-shrink-0">
<div className="flex items-center gap-2">
@ -35,12 +40,21 @@ export default function LandingNav() {
>
@samosachaat
</a>
<Link
href="/login"
className="px-3 py-1 rounded-full border border-warm-grey text-brown bg-cream-light hover:bg-cream transition-colors"
>
Sign in
</Link>
{authenticated ? (
<Link
href="/chat"
className="px-3 py-1 rounded-full border border-warm-grey text-brown bg-cream-light hover:bg-cream transition-colors"
>
Chat
</Link>
) : (
<Link
href="/login"
className="px-3 py-1 rounded-full border border-warm-grey text-brown bg-cream-light hover:bg-cream transition-colors"
>
Sign in
</Link>
)}
</div>
</nav>
);

View File

@ -1,8 +1,5 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export default function SessionBoundary({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
// SessionProvider (NextAuth) has been removed.
// This file is kept as a no-op for any residual imports.
export default function SessionBoundary({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@ -1,18 +1,19 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
import { PanelLeftOpen } from 'lucide-react';
import MessageBubble from './MessageBubble';
import EmptyState from './EmptyState';
import ChatInput from './ChatInput';
import { useChatStore } from '@/store/chatStore';
import { useSSE } from '@/hooks/useSSE';
import { useAuth } from '@/hooks/useAuth';
import { authHeaders } from '@/lib/auth-client';
import { parseSlashCommand } from '@/lib/slashCommands';
import type { Message } from '@/types/chat';
export default function ChatWindow() {
const { data: session } = useSession();
const { user } = useAuth();
const {
conversations,
currentConversationId,
@ -21,6 +22,7 @@ export default function ChatWindow() {
topK,
sidebarOpen,
toggleSidebar,
createConversation,
newConversation,
appendMessage,
updateMessage,
@ -67,7 +69,7 @@ export default function ChatWindow() {
updateMessage(
currentConversationId,
streamingMsgId,
`⚠️ Error: ${err.message}. Using mock responses requires only the frontend; cloud streaming requires CHAT_API_URL.`,
`Error: ${err.message}. Using mock responses requires only the frontend; cloud streaming requires CHAT_API_URL.`,
);
}
setStreamingMsgId(null);
@ -75,17 +77,21 @@ export default function ChatWindow() {
},
});
const ensureConversation = useCallback(() => {
const ensureConversation = useCallback(async () => {
if (currentConversationId) return currentConversationId;
// Try creating via the API first
const apiId = await createConversation();
if (apiId) return apiId;
// Fallback to local-only conversation (mock mode)
return newConversation();
}, [currentConversationId, newConversation]);
}, [currentConversationId, createConversation, newConversation]);
const handleSend = useCallback(
async (rawInput?: string) => {
const text = (rawInput ?? draft).trim();
if (!text || isStreaming) return;
const convId = ensureConversation();
const convId = await ensureConversation();
const slash = parseSlashCommand(text, { temperature, topK });
if (slash.handled) {
@ -93,7 +99,8 @@ export default function ChatWindow() {
if (slash.setTemperature !== undefined) setTemperature(slash.setTemperature);
if (slash.setTopK !== undefined) setTopK(slash.setTopK);
if (slash.clear) {
newConversation();
const apiId = await createConversation();
if (!apiId) newConversation();
return;
}
if (slash.consoleMessage) {
@ -116,7 +123,14 @@ export default function ChatWindow() {
.slice(0, -1)
.map((m) => ({ role: m.role, content: m.content }));
await start({ messages: history, model, temperature, topK });
await start({
messages: history,
model,
temperature,
topK,
conversationId: convId,
auth: authHeaders(),
});
},
[
draft,
@ -128,6 +142,7 @@ export default function ChatWindow() {
model,
setTemperature,
setTopK,
createConversation,
newConversation,
start,
],
@ -153,7 +168,7 @@ export default function ChatWindow() {
</span>
</div>
<div className="text-xs text-gray-500">
{session?.user?.name ? `Hi, ${session.user.name.split(' ')[0]}` : ''}
{user?.name ? `Hi, ${user.name.split(' ')[0]}` : ''}
</div>
</header>

View File

@ -2,14 +2,14 @@
import { useEffect } from 'react';
import Link from 'next/link';
import { useSession, signOut } from 'next-auth/react';
import { Plus, PanelLeftClose, PanelLeftOpen, LogOut, ChevronDown } from 'lucide-react';
import { Plus, PanelLeftClose, PanelLeftOpen, LogOut, ChevronDown, Trash2 } from 'lucide-react';
import SamosaLogo from '@/components/svg/SamosaLogo';
import { useChatStore, groupConversations, MODEL_OPTIONS } from '@/store/chatStore';
import { useAuth } from '@/hooks/useAuth';
import clsx from 'clsx';
export default function Sidebar() {
const { data: session } = useSession();
const { user, logout } = useAuth();
const {
conversations,
currentConversationId,
@ -17,14 +17,15 @@ export default function Sidebar() {
model,
setModel,
toggleSidebar,
newConversation,
createConversation,
selectConversation,
hydrateMockConversations,
deleteConversation,
fetchConversations,
} = useChatStore();
useEffect(() => {
hydrateMockConversations();
}, [hydrateMockConversations]);
fetchConversations();
}, [fetchConversations]);
const grouped = groupConversations(conversations);
@ -54,7 +55,7 @@ export default function Sidebar() {
<div className="px-3 py-3">
<button
type="button"
onClick={() => newConversation()}
onClick={() => createConversation()}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-gold/60 bg-white hover:bg-cream text-brown font-medium text-sm transition-colors"
>
<Plus size={16} className="text-gold" />
@ -72,12 +73,12 @@ export default function Sidebar() {
</div>
<ul className="space-y-0.5">
{items.map((c) => (
<li key={c.id}>
<li key={c.id} className="group relative">
<button
type="button"
onClick={() => selectConversation(c.id)}
className={clsx(
'w-full text-left px-2.5 py-1.5 rounded text-sm truncate transition-colors',
'w-full text-left px-2.5 py-1.5 rounded text-sm truncate transition-colors pr-8',
c.id === currentConversationId
? 'bg-cream text-brown font-medium'
: 'text-gray-700 hover:bg-cream/70',
@ -86,6 +87,17 @@ export default function Sidebar() {
>
{c.title}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
deleteConversation(c.id);
}}
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-cream text-gray-400 hover:text-chutney-red transition-all"
aria-label={`Delete ${c.title}`}
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
@ -121,20 +133,20 @@ export default function Sidebar() {
<div className="flex items-center gap-2 pt-1">
<div className="h-8 w-8 rounded-full bg-gold/20 text-brown flex items-center justify-center text-sm font-semibold">
{(session?.user?.name ?? 'G')[0].toUpperCase()}
{(user?.name ?? 'G')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">
{session?.user?.name ?? 'Guest'}
{user?.name ?? 'Guest'}
</div>
<div className="text-xs text-gray-500 truncate">
{session?.user?.email ?? 'Not signed in'}
{user?.email ?? 'Not signed in'}
</div>
</div>
<button
type="button"
aria-label="Sign out"
onClick={() => signOut({ callbackUrl: '/' })}
onClick={logout}
className="p-1.5 rounded hover:bg-cream text-gray-500 hover:text-brown"
>
<LogOut size={16} />

View File

@ -2,11 +2,11 @@
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useSession } from 'next-auth/react';
import { useAuth } from '@/hooks/useAuth';
export default function Hero() {
const { status } = useSession();
const ctaHref = status === 'authenticated' ? '/chat' : '/login';
const { authenticated } = useAuth();
const ctaHref = authenticated ? '/chat' : '/login';
return (
<section className="relative z-[2] text-center px-4 pt-6">

View File

@ -1,9 +1,5 @@
'use client';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
function GoogleIcon() {
return (
<svg width={20} height={20} viewBox="0 0 48 48" aria-hidden="true">
@ -27,40 +23,23 @@ function GitHubIcon() {
}
export default function OAuthButtons() {
const params = useSearchParams();
const callbackUrl = params.get('callbackUrl') || '/chat';
const [busy, setBusy] = useState<string | null>(null);
const handleSignIn = async (provider: 'google' | 'github') => {
setBusy(provider);
try {
await signIn(provider, { callbackUrl });
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<button
type="button"
onClick={() => handleSignIn('google')}
disabled={busy !== null}
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-60 transition-colors font-sans text-sm font-medium text-gray-700"
<a
href="/api/auth/google"
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 transition-colors font-sans text-sm font-medium text-gray-700"
>
<GoogleIcon />
<span>{busy === 'google' ? 'Redirecting…' : 'Continue with Google'}</span>
</button>
<span>Continue with Google</span>
</a>
<button
type="button"
onClick={() => handleSignIn('github')}
disabled={busy !== null}
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 disabled:opacity-60 transition-colors font-sans text-sm font-medium text-gray-700"
<a
href="/api/auth/github"
className="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 transition-colors font-sans text-sm font-medium text-gray-700"
>
<GitHubIcon />
<span>{busy === 'github' ? 'Redirecting…' : 'Continue with GitHub'}</span>
</button>
<span>Continue with GitHub</span>
</a>
</div>
);
}

View File

@ -0,0 +1,49 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
getToken,
setToken,
clearToken,
isAuthenticated,
getUser,
type TokenUser,
} from '@/lib/auth-client';
export function useAuth() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [user, setUser] = useState<TokenUser | null>(null);
const router = useRouter();
useEffect(() => {
setAuthenticated(isAuthenticated());
setUser(getUser());
setLoading(false);
}, []);
const logout = () => {
clearToken();
setAuthenticated(false);
setUser(null);
router.push('/');
};
return { authenticated, loading, user, logout };
}
/** Hook to capture access_token from the OAuth redirect query param */
export function useTokenCapture() {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
const token = searchParams.get('access_token');
if (token) {
setToken(token);
// Remove token from URL for cleanliness / security
router.replace('/chat');
}
}, [searchParams, router]);
}

View File

@ -13,6 +13,8 @@ export interface StreamRequest {
model?: string;
temperature?: number;
topK?: number;
conversationId?: string;
auth?: Record<string, string>;
}
export function useSSE(endpoint: string, options: SSEOptions = {}) {
@ -33,10 +35,21 @@ export function useSSE(endpoint: string, options: SSEOptions = {}) {
setIsStreaming(true);
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (body.auth) {
Object.assign(headers, body.auth);
}
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
headers,
body: JSON.stringify({
messages: body.messages,
model: body.model,
temperature: body.temperature,
topK: body.topK,
conversationId: body.conversationId,
}),
signal: ac.signal,
});

View File

@ -0,0 +1,52 @@
const TOKEN_KEY = 'samosachaat_access_token';
const USER_KEY = 'samosachaat_user';
export interface TokenUser {
name: string;
email: string;
}
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
// Decode JWT payload to persist basic user info
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const user: TokenUser = {
name: payload.name || payload.email || 'User',
email: payload.email || '',
};
localStorage.setItem(USER_KEY, JSON.stringify(user));
} catch {
/* malformed JWT — ignore */
}
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function isAuthenticated(): boolean {
return !!getToken();
}
export function getUser(): TokenUser | null {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? (JSON.parse(raw) as TokenUser) : null;
} catch {
return null;
}
}
export function authHeaders(): Record<string, string> {
const token = getToken();
if (!token) return {};
return { Authorization: `Bearer ${token}` };
}

View File

@ -1,19 +1,12 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth((req) => {
const { nextUrl } = req;
const isAuthed = !!req.auth;
const isChatRoute = nextUrl.pathname.startsWith('/chat');
if (isChatRoute && !isAuthed) {
const loginUrl = new URL('/login', nextUrl);
loginUrl.searchParams.set('callbackUrl', nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
export function middleware() {
// Auth is checked client-side via useAuth / localStorage.
// Middleware is intentionally a pass-through so the
// /chat?access_token=... redirect from the auth service works.
return NextResponse.next();
});
}
export const config = {
matcher: ['/chat/:path*'],
matcher: [], // No server-side auth blocking
};

View File

@ -13,7 +13,6 @@
"highlight.js": "^11.10.0",
"lucide-react": "^0.451.0",
"next": "14.2.15",
"next-auth": "5.0.0-beta.22",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
@ -46,37 +45,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.35.3.tgz",
"integrity": "sha512-g6qfiqU4OtyvIEZ8J7UoIwAxEnNnLJV0/f/DW41U+4G5nhBlaCrnKhawJIJpU0D3uavXLeDT3B0BkjtiimvMDA==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
"cookie": "0.6.0",
"jose": "^5.1.3",
"oauth4webapi": "^2.10.4",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -519,15 +487,6 @@
"node": ">=12.4.0"
}
},
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -580,12 +539,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@ -1970,15 +1923,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4154,15 +4098,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5419,33 +5354,6 @@
}
}
},
"node_modules/next-auth": {
"version": "5.0.0-beta.22",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.22.tgz",
"integrity": "sha512-QGBo9HGOjmnJBHGXvtFztl0tM5tL0porDlk74HVoCCzXd986ApOlIW3EmiCuho7YzEopgkFiwwmcXpoCrHAtYw==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.35.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0-0",
"nodemailer": "^6.6.5",
"react": "^18.2.0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -5520,15 +5428,6 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -6066,28 +5965,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -6098,12 +5975,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@ -10,28 +10,27 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^11.11.8",
"highlight.js": "^11.10.0",
"lucide-react": "^0.451.0",
"next": "14.2.15",
"next-auth": "5.0.0-beta.22",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.5",
"framer-motion": "^11.11.8",
"lucide-react": "^0.451.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"rehype-highlight": "^7.0.0",
"highlight.js": "^11.10.0",
"clsx": "^2.1.1"
"remark-gfm": "^4.0.0",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^20.16.10",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.15",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.15"
"typescript": "^5.6.2"
}
}

View File

@ -2,9 +2,11 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { authHeaders } from '@/lib/auth-client';
import type { Conversation, Message, ModelOption } from '@/types/chat';
const uid = () => Math.random().toString(36).slice(2, 10);
const now = () => Date.now();
export const MODEL_OPTIONS: ModelOption[] = [
{ id: 'nanochat-base', label: 'nanochat · base', description: 'Default cloud model' },
@ -12,65 +14,44 @@ export const MODEL_OPTIONS: ModelOption[] = [
{ id: 'nanochat-local', label: 'nanochat · local (WebGPU)', description: 'Browser GPU (experimental)' },
];
/* ------------------------------------------------------------------ */
/* State shape */
/* ------------------------------------------------------------------ */
interface ChatState {
/* data */
conversations: Conversation[];
currentConversationId: string | null;
/* settings (persisted in localStorage) */
model: string;
temperature: number;
topK: number;
sidebarOpen: boolean;
/* setting mutators */
setModel: (m: string) => void;
setTemperature: (t: number) => void;
setTopK: (k: number) => void;
toggleSidebar: () => void;
newConversation: () => string;
/* API-backed actions */
fetchConversations: () => Promise<void>;
fetchMessages: (conversationId: string) => Promise<void>;
createConversation: (title?: string) => Promise<string | null>;
deleteConversation: (id: string) => Promise<void>;
selectConversation: (id: string) => void;
deleteConversation: (id: string) => void;
/* local-only helpers (optimistic UI) */
newConversation: () => string;
appendMessage: (conversationId: string, message: Omit<Message, 'id' | 'createdAt'>) => string;
updateMessage: (conversationId: string, messageId: string, content: string) => void;
setConversationTitle: (id: string, title: string) => void;
hydrateMockConversations: () => void;
}
const now = () => Date.now();
const MOCK_CONVERSATIONS: Conversation[] = [
{
id: 'mock-1',
title: 'Why is samosa triangular?',
messages: [
{ id: 'm1', role: 'user', content: 'Why is samosa triangular?', createdAt: now() - 1000 * 60 * 30 },
{ id: 'm2', role: 'assistant', content: 'The triangular shape likely evolved for easy frying and portability — three edges crisp evenly and the pocket holds filling tightly.', createdAt: now() - 1000 * 60 * 29 },
],
createdAt: now() - 1000 * 60 * 30,
updatedAt: now() - 1000 * 60 * 29,
},
{
id: 'mock-2',
title: 'Chai masala recipe',
messages: [
{ id: 'm1', role: 'user', content: 'Classic masala chai recipe please', createdAt: now() - 1000 * 60 * 60 * 26 },
],
createdAt: now() - 1000 * 60 * 60 * 26,
updatedAt: now() - 1000 * 60 * 60 * 26,
},
{
id: 'mock-3',
title: 'Explain transformers simply',
messages: [],
createdAt: now() - 1000 * 60 * 60 * 24 * 4,
updatedAt: now() - 1000 * 60 * 60 * 24 * 4,
},
{
id: 'mock-4',
title: 'Monsoon pakora tips',
messages: [],
createdAt: now() - 1000 * 60 * 60 * 24 * 15,
updatedAt: now() - 1000 * 60 * 60 * 24 * 15,
},
];
/* ------------------------------------------------------------------ */
/* Store */
/* ------------------------------------------------------------------ */
export const useChatStore = create<ChatState>()(
persist(
@ -87,6 +68,109 @@ export const useChatStore = create<ChatState>()(
setTopK: (k) => set({ topK: k }),
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
/* ---- API actions ---- */
fetchConversations: async () => {
try {
const res = await fetch('/api/conversations', {
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
});
if (!res.ok) return;
const data = await res.json();
const list: Conversation[] = (data.conversations ?? []).map(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(c: any) => ({
id: c.id,
title: c.title ?? 'New chat',
messages: c.messages ?? [],
createdAt: c.createdAt ? new Date(c.createdAt).getTime() : now(),
updatedAt: c.updatedAt ? new Date(c.updatedAt).getTime() : now(),
}),
);
set({ conversations: list });
} catch (err) {
console.error('[chatStore] fetchConversations error:', err);
}
},
fetchMessages: async (conversationId) => {
try {
const res = await fetch(`/api/conversations/${conversationId}`, {
headers: { ...authHeaders() },
});
if (!res.ok) return;
const data = await res.json();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: Message[] = (data.messages ?? []).map((m: any) => ({
id: m.id ?? uid(),
role: m.role,
content: m.content,
createdAt: m.createdAt ? new Date(m.createdAt).getTime() : now(),
}));
set((s) => ({
conversations: s.conversations.map((c) =>
c.id === conversationId ? { ...c, messages } : c,
),
}));
} catch (err) {
console.error('[chatStore] fetchMessages error:', err);
}
},
createConversation: async (title) => {
try {
const res = await fetch('/api/conversations', {
method: 'POST',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title ?? 'New chat' }),
});
if (!res.ok) return null;
const data = await res.json();
const conv: Conversation = {
id: data.id,
title: data.title ?? title ?? 'New chat',
messages: [],
createdAt: data.createdAt ? new Date(data.createdAt).getTime() : now(),
updatedAt: data.updatedAt ? new Date(data.updatedAt).getTime() : now(),
};
set((s) => ({
conversations: [conv, ...s.conversations],
currentConversationId: conv.id,
}));
return conv.id;
} catch (err) {
console.error('[chatStore] createConversation error:', err);
return null;
}
},
deleteConversation: async (id) => {
try {
await fetch(`/api/conversations/${id}`, {
method: 'DELETE',
headers: { ...authHeaders() },
});
} catch (err) {
console.error('[chatStore] deleteConversation error:', err);
}
set((s) => {
const rest = s.conversations.filter((c) => c.id !== id);
return {
conversations: rest,
currentConversationId:
s.currentConversationId === id ? rest[0]?.id ?? null : s.currentConversationId,
};
});
},
selectConversation: (id) => {
set({ currentConversationId: id });
// Fetch latest messages for the selected conversation
get().fetchMessages(id);
},
/* ---- local-only helpers (for optimistic UI & mock mode) ---- */
newConversation: () => {
const id = uid();
const conv: Conversation = {
@ -100,17 +184,6 @@ export const useChatStore = create<ChatState>()(
return id;
},
selectConversation: (id) => set({ currentConversationId: id }),
deleteConversation: (id) =>
set((s) => {
const rest = s.conversations.filter((c) => c.id !== id);
return {
conversations: rest,
currentConversationId: s.currentConversationId === id ? rest[0]?.id ?? null : s.currentConversationId,
};
}),
appendMessage: (conversationId, message) => {
const id = uid();
set((s) => ({
@ -120,7 +193,10 @@ export const useChatStore = create<ChatState>()(
...c,
messages: [...c.messages, { ...message, id, createdAt: now() }],
updatedAt: now(),
title: c.title === 'New chat' && message.role === 'user' ? message.content.slice(0, 48) : c.title,
title:
c.title === 'New chat' && message.role === 'user'
? message.content.slice(0, 48)
: c.title,
}
: c,
),
@ -134,7 +210,9 @@ export const useChatStore = create<ChatState>()(
c.id === conversationId
? {
...c,
messages: c.messages.map((m) => (m.id === messageId ? { ...m, content } : m)),
messages: c.messages.map((m) =>
m.id === messageId ? { ...m, content } : m,
),
updatedAt: now(),
}
: c,
@ -145,27 +223,24 @@ export const useChatStore = create<ChatState>()(
set((s) => ({
conversations: s.conversations.map((c) => (c.id === id ? { ...c, title } : c)),
})),
hydrateMockConversations: () => {
const { conversations } = get();
if (conversations.length === 0) {
set({ conversations: MOCK_CONVERSATIONS, currentConversationId: MOCK_CONVERSATIONS[0].id });
}
},
}),
{
name: 'samosachaat-chat',
name: 'samosachaat-settings',
// Only persist user preferences — conversations live in the DB
partialize: (s) => ({
conversations: s.conversations,
currentConversationId: s.currentConversationId,
model: s.model,
temperature: s.temperature,
topK: s.topK,
sidebarOpen: s.sidebarOpen,
}),
},
),
);
/* ------------------------------------------------------------------ */
/* Grouping helper (unchanged) */
/* ------------------------------------------------------------------ */
export function groupConversations(conversations: Conversation[]) {
const day = 1000 * 60 * 60 * 24;
const t = now();