diff --git a/README-ZH.md b/README-ZH.md index 51fb499..b765928 100644 --- a/README-ZH.md +++ b/README-ZH.md @@ -68,8 +68,8 @@ E2B_API_KEY=your_e2b_api_key GLM_MODLE=glm-4-flash # Neon Auth(认证功能必填) -PUBLIC_NEON_AUTH_URL=your_neon_auth_url -NEON_DATA_PUBLIC_API_URL=your_neon_api_url +NEON_AUTH_BASE_URL=your_neon_auth_url +NEON_AUTH_COOKIE_SECRET=your_cookie_secret # Cloudflare R2(文件上传功能必填) CLOUDFLARE_ACCOUNT_ID=your_account_id diff --git a/README.md b/README.md index 52a182f..a5dbe67 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ E2B_API_KEY=your_e2b_api_key GLM_MODLE=glm-4-flash # Neon Auth (Required for authentication) -PUBLIC_NEON_AUTH_URL=your_neon_auth_url -NEON_DATA_PUBLIC_API_URL=your_neon_api_url +NEON_AUTH_BASE_URL=your_neon_auth_url +NEON_AUTH_COOKIE_SECRET=your_cookie_secret # Cloudflare R2 (Required for file upload) CLOUDFLARE_ACCOUNT_ID=your_account_id diff --git a/app/api/auth/[...path]/route.ts b/app/api/auth/[...path]/route.ts new file mode 100644 index 0000000..16ed1c4 --- /dev/null +++ b/app/api/auth/[...path]/route.ts @@ -0,0 +1,3 @@ +import { auth } from "@/lib/auth/server"; + +export const { GET, POST } = auth.handler(); diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx index a2c4604..0f7ef35 100644 --- a/app/chat/[id]/page.tsx +++ b/app/chat/[id]/page.tsx @@ -129,15 +129,15 @@ const ChatPage = ({ params }: ChatPageProps) => {
{isMobile ? ( -
+
-
+
{ className="flex flex-1 overflow-hidden" > -
- +
+ -
+
{ : "..."; return ( -
+
{isExpanded && ( -
+

{t("arguments")}

-
+						
 							{JSON.stringify(event.args, null, 2)}
 						
@@ -80,7 +79,7 @@ const ToolEventItem = memo(({ event }: ToolEventItemProps) => {

{t("result")}

-
+							
 								{JSON.stringify(event.result, null, 2)}
 							
@@ -99,7 +98,7 @@ const DebugPanel = memo(() => { const t = useTranslations("debug"); return ( -
+
{isOpen && ( - +
{toolEvents.length === 0 ? (

{t("noEvents")} @@ -132,7 +131,7 @@ const DebugPanel = memo(() => { )) )} - +

)}
); diff --git a/app/chat/components/message-item.tsx b/app/chat/components/message-item.tsx index 8d9f5ff..6309cc6 100644 --- a/app/chat/components/message-item.tsx +++ b/app/chat/components/message-item.tsx @@ -31,7 +31,7 @@ const ReasoningBlock = memo(({ text }: { text: string }) => { {t("thinking")}
-

+

{text}

@@ -167,7 +167,7 @@ const MessageItem = memo(
@@ -195,7 +195,9 @@ const MessageItem = memo( isUser ? "bg-primary text-primary-foreground" : "bg-muted", )} > -

{textPart.text}

+

+ {textPart.text} +

); })} diff --git a/app/providers.tsx b/app/providers.tsx index f93295d..71ea84f 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -1,16 +1,34 @@ "use client"; +import jotaiStore, { logoutAtom, userAtom } from "@/atoms"; import { Toaster } from "@/components/ui/sonner"; -import { getUserInfo } from "@/services/user"; +import { authClient } from "@/lib/auth/client"; +import type { User } from "@/types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect } from "react"; const queryClient = new QueryClient(); const AppProviders = ({ children }: { children: React.ReactNode }) => { + const { data: session } = authClient.useSession(); + useEffect(() => { - getUserInfo(); - }, []); + if (session?.user) { + const { id, name, email, image, createdAt, updatedAt } = session.user; + const user: User = { + id, + userName: name, + email, + avatar: image || null, + createdAt: new Date(createdAt).toISOString(), + updatedAt: new Date(updatedAt).toISOString(), + }; + jotaiStore.set(userAtom, user); + return; + } + + jotaiStore.set(logoutAtom); + }, [session]); return ( diff --git a/components/share/avatar.tsx b/components/share/avatar.tsx index e782119..1dd0e98 100644 --- a/components/share/avatar.tsx +++ b/components/share/avatar.tsx @@ -58,14 +58,16 @@ const Avatar = ({ mode = "default", className = "" }: AvatarProps) => { return ( - {/* Avatar */} -
+
+
{t("myAccount")} diff --git a/components/share/header.tsx b/components/share/header.tsx index 7d0a62d..8946939 100644 --- a/components/share/header.tsx +++ b/components/share/header.tsx @@ -35,6 +35,14 @@ const Header = () => { return ( <> + {!user?.id && ( + + )}
{/* Sidebar toggle button */} )}
@@ -122,11 +131,12 @@ const Header = () => { ) : (
- +
)}
diff --git a/components/share/input-field.tsx b/components/share/input-field.tsx index 17301d8..c78c8c9 100644 --- a/components/share/input-field.tsx +++ b/components/share/input-field.tsx @@ -1,6 +1,7 @@ "use client"; -import { firstUserInputAtom } from "@/atoms"; +import { firstUserInputAtom, userAtom } from "@/atoms"; +import loginDialogAtom from "@/atoms/login-dialog"; import { InputGroup, InputGroupAddon, @@ -78,6 +79,8 @@ const InputField = ({ const router = useRouter(); const [firstUserInput, setFirstUserInput] = useAtom(firstUserInputAtom); + const [user] = useAtom(userAtom); + const [, setIsLoginDialogOpen] = useAtom(loginDialogAtom); const [attachments, setAttachments] = useState([]); const [uploadedFiles, setUploadedFiles] = useState([]); @@ -210,6 +213,11 @@ const InputField = ({ let newInput = ""; if (firstUserInput && isHome) { + if (!user?.id) { + setIsLoginDialogOpen(true); + return; + } + const sessionID = generateId(); router.push(`/chat/${sessionID}`); return; diff --git a/components/share/login-dialog.tsx b/components/share/login-dialog.tsx index 89bccb2..c66255b 100644 --- a/components/share/login-dialog.tsx +++ b/components/share/login-dialog.tsx @@ -48,10 +48,12 @@ const LoginDialog = ({ open, onOpenChange, loginText, + showTrigger = true, }: { open?: boolean; onOpenChange?: (open: boolean) => void; loginText?: string; + showTrigger?: boolean; }) => { const t = useTranslations("login"); const [isLoading, setIsLoading] = useState(false); @@ -142,11 +144,13 @@ const LoginDialog = ({ onOpenChange?.(isOpen); }} > - - - + {showTrigger && ( + + + + )} { }); }); +describe("createAuthAwareChatFetch", () => { + beforeEach(() => { + jotaiStore.set(loginDialogAtom, false); + }); + + it("opens the login dialog when chat requests return 401", async () => { + const authAwareFetch = createAuthAwareChatFetch(async () => { + return { status: 401 } as Response; + }); + + await authAwareFetch("/api/chat"); + + expect(jotaiStore.get(loginDialogAtom)).toBe(true); + }); + + it("keeps the login dialog closed for non-401 responses", async () => { + const authAwareFetch = createAuthAwareChatFetch(async () => { + return { status: 200 } as Response; + }); + + await authAwareFetch("/api/chat"); + + expect(jotaiStore.get(loginDialogAtom)).toBe(false); + }); +}); + describe("formatRelativeDate", () => { const { formatRelativeDate } = require("@/lib/utils"); diff --git a/hooks/use-chat.tsx b/hooks/use-chat.tsx index 25a99e8..072487f 100644 --- a/hooks/use-chat.tsx +++ b/hooks/use-chat.tsx @@ -7,9 +7,10 @@ import { dispatchToolEventAtom, vncUrlAtom, } from "@/atoms/chat"; +import loginDialogAtom from "@/atoms/login-dialog"; import type { ToolCallEvent } from "@/types/chat"; import { useChat } from "@ai-sdk/react"; -import type { UIMessage } from "ai"; +import { DefaultChatTransport, type UIMessage } from "ai"; import { useCallback, useEffect, useRef, useState } from "react"; type ChatStatus = "submitted" | "streaming" | "ready" | "error"; @@ -39,6 +40,20 @@ type UseChatReturn = { stop: () => void; }; +const createAuthAwareChatFetch = ( + fetchImpl: typeof fetch = fetch, +): typeof fetch => { + return async (input, init) => { + const response = await fetchImpl(input, init); + + if (response.status === 401 && typeof window !== "undefined") { + jotaiStore.set(loginDialogAtom, true); + } + + return response; + }; +}; + const extractToolEventsFromMessages = ( messages: UIMessage[], existingMap: Map, @@ -112,7 +127,7 @@ const extractToolEventsFromMessages = ( }; export type { UseChatReturn, ChatStatus }; -export { extractToolEventsFromMessages }; +export { createAuthAwareChatFetch, extractToolEventsFromMessages }; const useAgentChat = (options: UseChatOptions = {}): UseChatReturn => { const { @@ -127,10 +142,21 @@ const useAgentChat = (options: UseChatOptions = {}): UseChatReturn => { const [thinkingTime, setThinkingTime] = useState(null); const reasoningStartTimeRef = useRef(null); const processedToolCallsRef = useRef(new Map()); + const transportApiRef = useRef(api); + const transportRef = useRef | null>(null); + + if (!transportRef.current || transportApiRef.current !== api) { + transportRef.current = new DefaultChatTransport({ + api, + fetch: createAuthAwareChatFetch(), + }); + transportApiRef.current = api; + } const chat = useChat({ id: sessionId, messages: initialMessages, + transport: transportRef.current, }); const messages = chat.messages; @@ -199,9 +225,9 @@ const useAgentChat = (options: UseChatOptions = {}): UseChatReturn => { jotaiStore.set(clearToolEventsAtom); jotaiStore.set(agentStatusAtom, "thinking"); - await chat.sendMessage({ text }, { body: { ...opts?.body, api } }); + await chat.sendMessage({ text }, { body: opts?.body }); }, - [chat, api], + [chat], ); const reload = useCallback(async () => { diff --git a/infra/orm/drizzle.ts b/infra/drizzle.ts similarity index 100% rename from infra/orm/drizzle.ts rename to infra/drizzle.ts diff --git a/infra/neon/neon.ts b/infra/jwks.ts similarity index 100% rename from infra/neon/neon.ts rename to infra/jwks.ts diff --git a/infra/neon.ts b/infra/neon.ts new file mode 100644 index 0000000..e69de29 diff --git a/infra/r2/r2.ts b/infra/r2.ts similarity index 100% rename from infra/r2/r2.ts rename to infra/r2.ts diff --git a/jest.setup.ts b/jest.setup.ts index f0ec82a..b6cc22d 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -76,3 +76,16 @@ if (typeof globalThis.WritableStream === "undefined") { writable: true, }); } + +if (typeof globalThis.fetch === "undefined") { + Object.defineProperty(globalThis, "fetch", { + value: jest.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({}), + text: async () => "", + })), + writable: true, + }); +} diff --git a/lib/auth.ts b/lib/auth.ts deleted file mode 100644 index 24225f8..0000000 --- a/lib/auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createClient } from "@neondatabase/neon-js"; - -const neonAuthUrl = process.env.PUBLIC_NEON_AUTH_URL ?? ""; -const neonDataApiUrl = process.env.NEON_DATA_PUBLIC_API_URL ?? ""; - -// Docs: https://neon.com/docs/reference/javascript-sdk -// Auth & database query -export const client = createClient({ - auth: { - url: neonAuthUrl, - }, - dataApi: { - url: neonDataApiUrl, - }, -}); diff --git a/lib/auth/client.ts b/lib/auth/client.ts new file mode 100644 index 0000000..170ef65 --- /dev/null +++ b/lib/auth/client.ts @@ -0,0 +1,5 @@ +"use client"; + +import { createAuthClient } from "@neondatabase/auth/next"; + +export const authClient = createAuthClient(); diff --git a/lib/auth/server.ts b/lib/auth/server.ts new file mode 100644 index 0000000..de9db90 --- /dev/null +++ b/lib/auth/server.ts @@ -0,0 +1,15 @@ +import { createNeonAuth } from "@neondatabase/auth/next/server"; + +const getAuth = () => + createNeonAuth({ + baseUrl: process.env.NEON_AUTH_BASE_URL ?? "", + cookies: { + secret: process.env.NEON_AUTH_COOKIE_SECRET ?? "", + }, + }); + +export const auth = new Proxy({} as ReturnType, { + get(_target, prop: string | symbol) { + return Reflect.get(getAuth(), prop); + }, +}); diff --git a/lib/indexeddb/chat.test.ts b/lib/indexeddb/chat.test.ts index a82208e..0d51034 100644 --- a/lib/indexeddb/chat.test.ts +++ b/lib/indexeddb/chat.test.ts @@ -1,7 +1,7 @@ import type { UIMessage } from "ai"; import { buildMessageRows } from "./chat"; -const makeMessage = ( +const makeTextMessage = ( id: string, role: UIMessage["role"], text: string, @@ -11,23 +11,41 @@ const makeMessage = ( parts: [{ type: "text", text }], }); -describe("buildMessageRows", () => { - it("keeps message order stable within the same save batch", () => { +describe("chat indexeddb storage", () => { + it("builds message rows with the owning user id", () => { + const sessionId = `session-${crypto.randomUUID()}`; + const userId = `user-${crypto.randomUUID()}`; const messages = [ - makeMessage("user-1", "user", "first question"), - makeMessage("assistant-1", "assistant", "first answer"), - makeMessage("user-2", "user", "follow up"), + makeTextMessage(`message-${crypto.randomUUID()}`, "user", "first user"), ]; - const rows = buildMessageRows(messages, "session-1", 1_710_000_000_000); + const rows = buildMessageRows(messages, sessionId, userId); - expect(rows.map((row) => row.id)).toEqual([ - "user-1", - "assistant-1", - "user-2", - ]); - expect(rows.map((row) => row.createdAt)).toEqual([ - 1_710_000_000_000_000, 1_710_000_000_000_001, 1_710_000_000_000_002, - ]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: messages[0]?.id, + userId, + sessionId, + role: "user", + }); + }); + + it("keeps batched message ordering stable", () => { + const sessionId = `session-${crypto.randomUUID()}`; + const userId = `user-${crypto.randomUUID()}`; + const batchTimestamp = Date.now(); + const messages = [ + makeTextMessage(`message-${crypto.randomUUID()}`, "user", "first user"), + makeTextMessage( + `message-${crypto.randomUUID()}`, + "assistant", + "second user", + ), + ]; + + const rows = buildMessageRows(messages, sessionId, userId, batchTimestamp); + + expect(rows[0]?.createdAt).toBe(batchTimestamp * 1000); + expect(rows[1]?.createdAt).toBe(batchTimestamp * 1000 + 1); }); }); diff --git a/lib/indexeddb/chat.ts b/lib/indexeddb/chat.ts index 8949f31..616a860 100644 --- a/lib/indexeddb/chat.ts +++ b/lib/indexeddb/chat.ts @@ -3,6 +3,7 @@ import Dexie, { type EntityTable } from "dexie"; type SessionRow = { id: string; + userId: string; title: string; createdAt: number; updatedAt: number; @@ -10,6 +11,7 @@ type SessionRow = { type MessageRow = { id: string; + userId: string; sessionId: string; role: string; parts: UIMessage["parts"]; @@ -17,51 +19,73 @@ type MessageRow = { }; const MESSAGE_BATCH_PRECISION = 1000; +const CHAT_DB_NAME = "firewave-agent-v2"; -const db = new Dexie("firewave-agent") as Dexie & { +const db = new Dexie(CHAT_DB_NAME) as Dexie & { sessions: EntityTable; messages: EntityTable; }; db.version(1).stores({ - sessions: "id, updatedAt", - messages: "id, sessionId, createdAt", + sessions: "id, userId, updatedAt, [userId+updatedAt]", + messages: "id, userId, sessionId, createdAt, [userId+sessionId]", }); -const createSession = async (id: string, title: string) => { +const createSession = async (id: string, title: string, userId: string) => { const now = Date.now(); - await db.sessions.add({ id, title, createdAt: now, updatedAt: now }); - return { id, title, createdAt: now, updatedAt: now }; + await db.sessions.add({ id, userId, title, createdAt: now, updatedAt: now }); + return { id, userId, title, createdAt: now, updatedAt: now }; }; -const getAllSessions = async (): Promise => { - return db.sessions.orderBy("updatedAt").reverse().toArray(); +const getAllSessions = async (userId: string): Promise => { + const sessions = await db.sessions + .where("userId") + .equals(userId) + .sortBy("updatedAt"); + return sessions.reverse(); }; -const getSession = async (id: string) => { - return db.sessions.get(id); +const getSession = async (id: string, userId: string) => { + const session = await db.sessions.get(id); + return session?.userId === userId ? session : undefined; }; -const updateSessionTitle = async (id: string, title: string) => { +const updateSessionTitle = async ( + id: string, + title: string, + userId: string, +) => { + const session = await getSession(id, userId); + if (!session) { + return; + } + await db.sessions.update(id, { title, updatedAt: Date.now() }); }; -const deleteSession = async (id: string) => { +const deleteSession = async (id: string, userId: string) => { await db.transaction("rw", [db.sessions, db.messages], async () => { + const session = await getSession(id, userId); + if (!session) { + return; + } + await db.sessions.delete(id); - await db.messages.where("sessionId").equals(id).delete(); + await db.messages.where("[userId+sessionId]").equals([userId, id]).delete(); }); }; const buildMessageRows = ( messages: UIMessage[], sessionId: string, + userId: string, batchTimestamp = Date.now(), ): MessageRow[] => { const batchBase = batchTimestamp * MESSAGE_BATCH_PRECISION; return messages.map((msg, index) => ({ id: msg.id, + userId, sessionId, role: msg.role, parts: msg.parts, @@ -69,8 +93,17 @@ const buildMessageRows = ( })); }; -const saveMessages = async (messages: UIMessage[], sessionId: string) => { - const rows = buildMessageRows(messages, sessionId); +const saveMessages = async ( + messages: UIMessage[], + sessionId: string, + userId: string, +) => { + const session = await getSession(sessionId, userId); + if (!session) { + return; + } + + const rows = buildMessageRows(messages, sessionId, userId); await db.transaction("rw", [db.messages, db.sessions], async () => { await db.messages.bulkPut(rows); @@ -78,10 +111,13 @@ const saveMessages = async (messages: UIMessage[], sessionId: string) => { }); }; -const getMessages = async (sessionId: string): Promise => { +const getMessages = async ( + sessionId: string, + userId: string, +): Promise => { const rows = await db.messages - .where("sessionId") - .equals(sessionId) + .where("[userId+sessionId]") + .equals([userId, sessionId]) .sortBy("createdAt"); return rows.map((row) => ({ diff --git a/lib/neon-auth.ts b/lib/neon-auth.ts deleted file mode 100644 index 54e6160..0000000 --- a/lib/neon-auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createClient } from "@neondatabase/neon-js"; - -const neonAuthUrl = process.env.NEXT_PUBLIC_NEON_AUTH_URL ?? ""; -const neonDataApiUrl = process.env.NEXT_PUBLIC_NEON_DATA_PUBLIC_API_URL ?? ""; - -// Docs: https://neon.com/docs/reference/javascript-sdk -// Auth & database query -export const client = createClient({ - auth: { - url: neonAuthUrl, - }, - dataApi: { - url: neonDataApiUrl, - }, -}); diff --git a/middleware.ts b/middleware.ts index 84b28e2..99f4ecb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,15 +1,20 @@ +import authMiddleware from "@/middlewares/auth"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -export async function middleware(_request: NextRequest) { +const middlewares = [authMiddleware]; + +export async function middleware(request: NextRequest) { + for (const mw of middlewares) { + const response = await mw(request); + if (response && response !== NextResponse.next()) { + return response; + } + } + return NextResponse.next(); } export const config = { - matcher: [ - "/settings/:path*", - "/mycreations/:path*", - "/myfavourite/:path*", - "/morecredits/:path*", - ], + matcher: ["/settings/:path*", "/chat/:path*", "/api/:path*"], }; diff --git a/middlewares/auth.ts b/middlewares/auth.ts new file mode 100644 index 0000000..b2235ef --- /dev/null +++ b/middlewares/auth.ts @@ -0,0 +1,35 @@ +import { auth } from "@/lib/auth/server"; +import type { ApiResponse } from "@/types/api"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const authMiddleware = async (request: NextRequest) => { + if (request.nextUrl.pathname.startsWith("/api/auth")) { + return NextResponse.next(); + } + + const { data: session } = await auth.getSession(); + + if ( + !session?.user && + !request.nextUrl.pathname.startsWith("/login") && + !request.nextUrl.pathname.startsWith("/auth") + ) { + if (request.nextUrl.pathname.startsWith("/api")) { + const body: ApiResponse = { + code: 401, + success: false, + message: "Unauthorized request", + }; + return NextResponse.json(body, { status: 401 }); + } + + const url = request.nextUrl.clone(); + url.pathname = "/"; + return NextResponse.redirect(url); + } + + return NextResponse.next(); +}; + +export default authMiddleware; diff --git a/package.json b/package.json index 1662911..24ff151 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@aws-sdk/client-s3": "^3.1024.0", "@e2b/code-interpreter": "^2.4.0", "@e2b/desktop": "^2.2.2", + "@neondatabase/auth": "0.2.0-beta.1", "@neondatabase/neon-js": "0.2.0-beta.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4849ced..dc3d7c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@e2b/desktop': specifier: ^2.2.2 version: 2.2.2 + '@neondatabase/auth': + specifier: 0.2.0-beta.1 + version: 0.2.0-beta.1(85ca8aaaf0dc70c8971f70acc0d3d7de) '@neondatabase/neon-js': specifier: 0.2.0-beta.1 version: 0.2.0-beta.1(85ca8aaaf0dc70c8971f70acc0d3d7de) @@ -10599,7 +10602,7 @@ snapshots: better-call@1.1.5(zod@4.3.6): dependencies: '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 + '@better-fetch/fetch': 1.1.21 rou3: 0.7.12 set-cookie-parser: 2.7.2 optionalDependencies: diff --git a/services/api-client.ts b/services/api-client.ts index 89b34f9..ff285ed 100644 --- a/services/api-client.ts +++ b/services/api-client.ts @@ -11,45 +11,21 @@ import { toast } from "sonner"; const baseURL = process.env.NODE_ENV === "development" ? "http://localhost:3000/api/v1" - : "https://agent-dashboard/api/v1"; + : "https://fire-wave/api/"; const apiClient = axios.create({ baseURL: baseURL, withCredentials: true, }); -// Define the type of business status code const resultEnum: Record = { success: 0, unauthorized: 401, sensitive: 105, }; -// 添加请求拦截器 apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - if (typeof window !== "undefined") { - const token = localStorage.getItem("token"); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } else { - // const cookies = document.cookie.split(";"); - // for (const cookie of cookies) { - // const eqIndex = cookie.indexOf("="); - // if (eqIndex === -1) continue; - // const name = cookie.substring(0, eqIndex).trim(); - // const value = cookie.substring(eqIndex + 1); - // if ( - // name.toLowerCase().includes("neon") || - // name.toLowerCase().includes("auth") || - // name.toLowerCase().includes("session") - // ) { - // config.headers.Authorization = `Bearer ${value}`; - // break; - // } - // } - } - } return config; }, (error) => Promise.reject(error), @@ -60,7 +36,7 @@ apiClient.interceptors.response.use( const { code, success, data, message } = response.data || {}; if (code === resultEnum.success && success) { - return data as unknown as AxiosResponse; // A mandatory declaration is AxiosResponse + return data as unknown as AxiosResponse; } const validMsg = message || "Internal unknown error"; @@ -80,7 +56,6 @@ apiClient.interceptors.response.use( if (status === 401) { if (typeof window !== "undefined") { - // Trigger login dialog to show jotaiStore.set(loginDialogAtom, true); } } diff --git a/services/chat.ts b/services/chat.ts index e614770..45aa9c1 100644 --- a/services/chat.ts +++ b/services/chat.ts @@ -1,3 +1,4 @@ +import { userAtom } from "@/atoms"; import { type SessionRow, createSession, @@ -15,11 +16,28 @@ import { useQueryClient, } from "@tanstack/react-query"; import type { UIMessage } from "ai"; +import { useAtomValue } from "jotai"; + +const useChatStorageUserId = () => { + const { id } = useAtomValue(userAtom); + return id; +}; + +const getRequiredUserId = (userId: string) => { + if (!userId) { + throw new Error("User is not authenticated"); + } + + return userId; +}; const useAllSessions = (): UseQueryResult => { + const userId = useChatStorageUserId(); + return useQuery({ - queryKey: ["allSessions"], - queryFn: getAllSessions, + queryKey: ["allSessions", userId], + queryFn: () => getAllSessions(userId), + enabled: !!userId, staleTime: 1000, }); }; @@ -27,10 +45,12 @@ const useAllSessions = (): UseQueryResult => { const useChatHistory = ( sessionId: string, ): UseQueryResult => { + const userId = useChatStorageUserId(); + return useQuery({ - queryKey: ["chatHistory", sessionId], - queryFn: () => getMessages(sessionId), - enabled: !!sessionId, + queryKey: ["chatHistory", userId, sessionId], + queryFn: () => getMessages(sessionId, userId), + enabled: !!sessionId && !!userId, staleTime: 1000, }); }; @@ -40,11 +60,13 @@ const useCreateSession = (): UseMutationResult< Error, { id: string; title: string } > => { + const userId = useChatStorageUserId(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, title }) => createSession(id, title), + mutationFn: ({ id, title }) => + createSession(id, title, getRequiredUserId(userId)), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["allSessions"] }); + queryClient.invalidateQueries({ queryKey: ["allSessions", userId] }); }, }); }; @@ -54,21 +76,28 @@ const useUpdateSessionTitle = (): UseMutationResult< Error, { sessionId: string; title: string } > => { + const userId = useChatStorageUserId(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ sessionId, title }) => updateSessionTitle(sessionId, title), + mutationFn: ({ sessionId, title }) => + updateSessionTitle(sessionId, title, getRequiredUserId(userId)), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["allSessions"] }); + queryClient.invalidateQueries({ queryKey: ["allSessions", userId] }); }, }); }; const useDeleteSession = (): UseMutationResult => { + const userId = useChatStorageUserId(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: deleteSession, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["allSessions"] }); + mutationFn: (sessionId) => + deleteSession(sessionId, getRequiredUserId(userId)), + onSuccess: (_data, sessionId) => { + queryClient.invalidateQueries({ queryKey: ["allSessions", userId] }); + queryClient.removeQueries({ + queryKey: ["chatHistory", userId, sessionId], + }); }, }); }; @@ -78,14 +107,16 @@ const useSaveMessages = (): UseMutationResult< Error, { messages: UIMessage[]; sessionId: string } > => { + const userId = useChatStorageUserId(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ messages, sessionId }) => saveMessages(messages, sessionId), + mutationFn: ({ messages, sessionId }) => + saveMessages(messages, sessionId, getRequiredUserId(userId)), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ - queryKey: ["chatHistory", variables.sessionId], + queryKey: ["chatHistory", userId, variables.sessionId], }); - queryClient.invalidateQueries({ queryKey: ["allSessions"] }); + queryClient.invalidateQueries({ queryKey: ["allSessions", userId] }); }, }); }; diff --git a/services/user.ts b/services/user.ts index 093303c..943cc93 100644 --- a/services/user.ts +++ b/services/user.ts @@ -1,99 +1,49 @@ -import jotaiStore, { logoutAtom, userAtom } from "@/atoms"; -import { handleError } from "@/lib/error-handler"; -import { client } from "@/lib/neon-auth"; -import type { User } from "@/types"; +import jotaiStore, { logoutAtom } from "@/atoms"; +import { authClient } from "@/lib/auth/client"; -type provider = "google" | "github" | "vercel"; - -const getUserInfo = async () => { - const { data, error } = await client.auth.getSession(); - if (error) { - handleError(error, "Failed to get user info"); - } - - if (data?.session) { - localStorage.setItem("token", data?.session.token); - } - - if (data?.user) { - const { id, name, email, image, createdAt, updatedAt, banned } = data.user; - - const user: User = { - id: id, - userName: name, - email: email, - avatar: image || null, - createdAt: createdAt.toISOString(), - updatedAt: updatedAt.toISOString(), - banned: banned, - }; - - jotaiStore.set(userAtom, user); - } else { - console.error("No active session"); - } -}; +type Provider = "google" | "github" | "vercel"; const sendSignInOtp = async (email: string) => { - const { error } = await client.auth.emailOtp.sendVerificationOtp({ - email: email, + const { error } = await authClient.emailOtp.sendVerificationOtp({ + email, type: "sign-in", }); - if (error) { - handleError(error); - } + if (error) throw error; }; const signInWithOtp = async (email: string, otpCode: string) => { - const { data, error } = await client.auth.signIn.emailOtp({ - email: email, + const { error } = await authClient.signIn.emailOtp({ + email, otp: otpCode, }); - - if (error) { - handleError(error, "Login faild"); - } - - console.log("Successfully login:", data); + if (error) throw error; }; -const handleOauthSignIn = async (provider: provider) => { - const callbackURL = process.env.DEV ? "http://localhost:3000" : ""; - - try { - await client.auth.signIn.social({ - provider: provider, - callbackURL: callbackURL || window.location.origin, - }); - } catch (error) { - handleError(error); - } +const handleOAuthSignIn = async (provider: Provider) => { + await authClient.signIn.social({ + provider, + callbackURL: window.location.origin, + }); }; const signInGoogle = async () => { - await handleOauthSignIn("google"); + await handleOAuthSignIn("google"); }; const signInGithub = async () => { - await handleOauthSignIn("github"); + await handleOAuthSignIn("github"); }; const signInVercel = async () => { - await handleOauthSignIn("vercel"); + await handleOAuthSignIn("vercel"); }; const signOut = async () => { - try { - await client.auth.signOut(); - jotaiStore.set(logoutAtom); - localStorage.removeItem("token"); - } catch (error) { - handleError(error); - } + await authClient.signOut(); + jotaiStore.set(logoutAtom); }; export { - getUserInfo, signOut, signInGithub, sendSignInOtp, diff --git a/tests/e2e/chat-flow.spec.ts b/tests/e2e/chat-flow.spec.ts index 8282af6..1b33907 100644 --- a/tests/e2e/chat-flow.spec.ts +++ b/tests/e2e/chat-flow.spec.ts @@ -68,7 +68,7 @@ test.describe("Chat History Persistence", () => { const hasSession = await page.evaluate(() => { return new Promise((resolve) => { - const request = indexedDB.open("firewave-agent"); + const request = indexedDB.open("firewave-agent-v2"); request.onsuccess = () => { const db = request.result; const tx = db.transaction("sessions", "readonly"); @@ -98,7 +98,7 @@ test.describe("Chat History Persistence", () => { const title = await page.evaluate(() => { return new Promise((resolve) => { - const request = indexedDB.open("firewave-agent"); + const request = indexedDB.open("firewave-agent-v2"); request.onsuccess = () => { const db = request.result; const tx = db.transaction("sessions", "readonly"); @@ -147,7 +147,7 @@ test.describe("Chat History Persistence", () => { const messageCount = await page.evaluate(() => { return new Promise((resolve) => { - const request = indexedDB.open("firewave-agent"); + const request = indexedDB.open("firewave-agent-v2"); request.onsuccess = () => { const db = request.result; const tx = db.transaction("messages", "readonly");