From aa7a907063c9ad4c34b9dd0e6bbf2d32953c9227 Mon Sep 17 00:00:00 2001 From: Manmohan Sharma Date: Thu, 16 Apr 2026 13:21:38 -0700 Subject: [PATCH] feat(frontend): wire frontend to real backend auth + chat-api services Remove NextAuth and replace with token-based auth against the backend auth service (OAuth + JWT). The frontend now redirects login to /api/auth/google and /api/auth/github (proxied by nginx to the auth service), captures the JWT from the redirect query param, and uses it for all API calls. Key changes: - Remove next-auth dependency and all NextAuth config/routes - Add lib/auth-client.ts (JWT token storage + auth headers) - Add hooks/useAuth.ts (client-side auth state + token capture) - Rewrite middleware.ts to pass-through (client-side auth only) - Login page uses plain links to /api/auth/{provider} - Chat page captures access_token from OAuth redirect - Zustand store fetches conversations from real chat-api via JWT - API routes proxy /api/conversations/* to chat-api with auth - chat/stream route supports conversationId + auth header forwarding - useSSE hook accepts auth headers for authenticated streaming - Sidebar loads conversations from API, supports delete - Landing page (Hero, LandingNav) uses useAuth instead of useSession - Add .env.production.example and scripts/generate-jwt-keys.sh Mock echo fallback preserved when CHAT_API_URL is not set. Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.production.example | 32 +++ scripts/generate-jwt-keys.sh | 15 ++ .../app/api/auth/[...nextauth]/route.ts | 3 - .../frontend/app/api/chat/stream/route.ts | 26 ++- .../api/conversations/[id]/messages/route.ts | 40 ++++ .../app/api/conversations/[id]/route.ts | 54 +++++ .../frontend/app/api/conversations/route.ts | 67 +++--- services/frontend/app/chat/page.tsx | 26 ++- services/frontend/app/layout.tsx | 3 +- services/frontend/app/login/page.tsx | 28 ++- services/frontend/auth.ts | 40 +--- services/frontend/components/LandingNav.tsx | 26 ++- .../frontend/components/SessionBoundary.tsx | 11 +- .../frontend/components/chat/ChatWindow.tsx | 33 ++- services/frontend/components/chat/Sidebar.tsx | 40 ++-- services/frontend/components/landing/Hero.tsx | 6 +- .../components/login/OAuthButtons.tsx | 41 +--- services/frontend/hooks/useAuth.ts | 49 +++++ services/frontend/hooks/useSSE.ts | 17 +- services/frontend/lib/auth-client.ts | 52 +++++ services/frontend/middleware.ts | 19 +- services/frontend/package-lock.json | 129 ----------- services/frontend/package.json | 19 +- services/frontend/store/chatStore.ts | 201 ++++++++++++------ 24 files changed, 608 insertions(+), 369 deletions(-) create mode 100644 .env.production.example create mode 100755 scripts/generate-jwt-keys.sh delete mode 100644 services/frontend/app/api/auth/[...nextauth]/route.ts create mode 100644 services/frontend/app/api/conversations/[id]/messages/route.ts create mode 100644 services/frontend/app/api/conversations/[id]/route.ts create mode 100644 services/frontend/hooks/useAuth.ts create mode 100644 services/frontend/lib/auth-client.ts diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 00000000..2a13bd78 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/scripts/generate-jwt-keys.sh b/scripts/generate-jwt-keys.sh new file mode 100755 index 00000000..fad348f0 --- /dev/null +++ b/scripts/generate-jwt-keys.sh @@ -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." diff --git a/services/frontend/app/api/auth/[...nextauth]/route.ts b/services/frontend/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 0a98352f..00000000 --- a/services/frontend/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from '@/auth'; - -export const { GET, POST } = handlers; diff --git a/services/frontend/app/api/chat/stream/route.ts b/services/frontend/app/api/chat/stream/route.ts index 494f6141..604eafce 100644 --- a/services/frontend/app/api/chat/stream/route.ts +++ b/services/frontend/app/api/chat/stream/route.ts @@ -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) { 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 = { '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); } diff --git a/services/frontend/app/api/conversations/[id]/messages/route.ts b/services/frontend/app/api/conversations/[id]/messages/route.ts new file mode 100644 index 00000000..f71e143d --- /dev/null +++ b/services/frontend/app/api/conversations/[id]/messages/route.ts @@ -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 }); + } +} diff --git a/services/frontend/app/api/conversations/[id]/route.ts b/services/frontend/app/api/conversations/[id]/route.ts new file mode 100644 index 00000000..ba9853c4 --- /dev/null +++ b/services/frontend/app/api/conversations/[id]/route.ts @@ -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 }); + } +} diff --git a/services/frontend/app/api/conversations/route.ts b/services/frontend/app/api/conversations/route.ts index 9e4b60d7..1b018aa8 100644 --- a/services/frontend/app/api/conversations/route.ts +++ b/services/frontend/app/api/conversations/route.ts @@ -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 }); + } } diff --git a/services/frontend/app/chat/page.tsx b/services/frontend/app/chat/page.tsx index 6e0e1a19..bea612b0 100644 --- a/services/frontend/app/chat/page.tsx +++ b/services/frontend/app/chat/page.tsx @@ -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
Loading...
; + } + if (!authenticated) { + redirect('/login'); + return null; + } -export default function ChatPage() { return (
@@ -11,3 +25,11 @@ export default function ChatPage() {
); } + +export default function ChatPage() { + return ( + Loading...}> + + + ); +} diff --git a/services/frontend/app/layout.tsx b/services/frontend/app/layout.tsx index 65a085c4..2f86f67e 100644 --- a/services/frontend/app/layout.tsx +++ b/services/frontend/app/layout.tsx @@ -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 ( - {children} + {children} ); diff --git a/services/frontend/app/login/page.tsx b/services/frontend/app/login/page.tsx index 326255c5..ee135b8c 100644 --- a/services/frontend/app/login/page.tsx +++ b/services/frontend/app/login/page.tsx @@ -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 (
@@ -30,9 +36,7 @@ export default async function LoginPage() {

- +
); } + +export default function LoginPage() { + return ( + Loading...}> + + + ); +} diff --git a/services/frontend/auth.ts b/services/frontend/auth.ts index b9d98dbc..1bc12ff8 100644 --- a/services/frontend/auth.ts +++ b/services/frontend/auth.ts @@ -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 {}; diff --git a/services/frontend/components/LandingNav.tsx b/services/frontend/components/LandingNav.tsx index 701f2a9c..15819145 100644 --- a/services/frontend/components/LandingNav.tsx +++ b/services/frontend/components/LandingNav.tsx @@ -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 (
); diff --git a/services/frontend/components/SessionBoundary.tsx b/services/frontend/components/SessionBoundary.tsx index 742bb706..35345751 100644 --- a/services/frontend/components/SessionBoundary.tsx +++ b/services/frontend/components/SessionBoundary.tsx @@ -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 {children}; +// 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}; } diff --git a/services/frontend/components/chat/ChatWindow.tsx b/services/frontend/components/chat/ChatWindow.tsx index 851653aa..6b6a61b6 100644 --- a/services/frontend/components/chat/ChatWindow.tsx +++ b/services/frontend/components/chat/ChatWindow.tsx @@ -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() {
- {session?.user?.name ? `Hi, ${session.user.name.split(' ')[0]}` : ''} + {user?.name ? `Hi, ${user.name.split(' ')[0]}` : ''}
diff --git a/services/frontend/components/chat/Sidebar.tsx b/services/frontend/components/chat/Sidebar.tsx index 75de8750..319ad622 100644 --- a/services/frontend/components/chat/Sidebar.tsx +++ b/services/frontend/components/chat/Sidebar.tsx @@ -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() {
@@ -121,20 +133,20 @@ export default function Sidebar() {
- {(session?.user?.name ?? 'G')[0].toUpperCase()} + {(user?.name ?? 'G')[0].toUpperCase()}
- {session?.user?.name ?? 'Guest'} + {user?.name ?? 'Guest'}
- {session?.user?.email ?? 'Not signed in'} + {user?.email ?? 'Not signed in'}
+ Continue with Google + - + Continue with GitHub +
); } diff --git a/services/frontend/hooks/useAuth.ts b/services/frontend/hooks/useAuth.ts new file mode 100644 index 00000000..a7616b69 --- /dev/null +++ b/services/frontend/hooks/useAuth.ts @@ -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(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]); +} diff --git a/services/frontend/hooks/useSSE.ts b/services/frontend/hooks/useSSE.ts index d23f6410..e107cc07 100644 --- a/services/frontend/hooks/useSSE.ts +++ b/services/frontend/hooks/useSSE.ts @@ -13,6 +13,8 @@ export interface StreamRequest { model?: string; temperature?: number; topK?: number; + conversationId?: string; + auth?: Record; } export function useSSE(endpoint: string, options: SSEOptions = {}) { @@ -33,10 +35,21 @@ export function useSSE(endpoint: string, options: SSEOptions = {}) { setIsStreaming(true); try { + const headers: Record = { '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, }); diff --git a/services/frontend/lib/auth-client.ts b/services/frontend/lib/auth-client.ts new file mode 100644 index 00000000..c4adb4c2 --- /dev/null +++ b/services/frontend/lib/auth-client.ts @@ -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 { + const token = getToken(); + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; +} diff --git a/services/frontend/middleware.ts b/services/frontend/middleware.ts index 88fae394..75534bf5 100644 --- a/services/frontend/middleware.ts +++ b/services/frontend/middleware.ts @@ -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 }; diff --git a/services/frontend/package-lock.json b/services/frontend/package-lock.json index c1d36dc1..a5c1b2a3 100644 --- a/services/frontend/package-lock.json +++ b/services/frontend/package-lock.json @@ -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", diff --git a/services/frontend/package.json b/services/frontend/package.json index 0d6c5025..42aefc8c 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -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" } } diff --git a/services/frontend/store/chatStore.ts b/services/frontend/store/chatStore.ts index b93b4a7e..4c5be0cf 100644 --- a/services/frontend/store/chatStore.ts +++ b/services/frontend/store/chatStore.ts @@ -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; + fetchMessages: (conversationId: string) => Promise; + createConversation: (title?: string) => Promise; + deleteConversation: (id: string) => Promise; selectConversation: (id: string) => void; - deleteConversation: (id: string) => void; + + /* local-only helpers (optimistic UI) */ + newConversation: () => string; appendMessage: (conversationId: string, message: Omit) => 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()( persist( @@ -87,6 +68,109 @@ export const useChatStore = create()( 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()( 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()( ...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()( 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()( 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();