- Cookie-based auth via backend proxy (httpOnly JWTs) - Supabase Postgres persistence for sessions/messages/profiles + RLS - Fix cross-user session leak (token cache keyed by full token, not 50-char prefix) - Fix missing table grants (42501) via migration; auto-provision profiles on user creation - Chat validation, ownership checks, rate limiting, /api/chat/sessions route ordering - Frontend auth-state reset + credentials include - OpenRouter AI provider (OpenAI-compatible base URL, reasoning disabled) - Tests: chatValidation, appState
557 lines
17 KiB
React
557 lines
17 KiB
React
/* ========================================
|
||
App Component — padhle (Backend-Connected)
|
||
======================================== */
|
||
|
||
import { useState, useRef, useCallback, useEffect } from "react";
|
||
import Sidebar from "./components/sidebar/Sidebar";
|
||
import TopNav from "./components/top-nav/TopNav";
|
||
import ChatHistory from "./components/chat-history/ChatHistory";
|
||
import ChatInput from "./components/chat-input/ChatInput";
|
||
import SelectorFlow from "./components/selector-flow/SelectorFlow";
|
||
import { useAuth } from "./lib/auth/SupabaseAuth";
|
||
import SignInModal from "./lib/auth/SignIn";
|
||
import { resetAppState } from "./appState";
|
||
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev
|
||
|
||
// Default selectors
|
||
const DEFAULTS = {
|
||
grade: "Choose standard",
|
||
subject: "choose-subject",
|
||
chapter: "choose-chapter",
|
||
};
|
||
|
||
/* ─── Selector data ─── */
|
||
|
||
const gradeItems = [
|
||
{ id: "grade-6", label: "Grade 6", icon: "backpack" },
|
||
{ id: "grade-7", label: "Grade 7", icon: "auto_stories" },
|
||
{ id: "grade-8", label: "Grade 8", icon: "menu_book" },
|
||
{ id: "grade-9", label: "Grade 9", icon: "science" },
|
||
{ id: "grade-10", label: "Grade 10", icon: "calculate" },
|
||
{ id: "grade-11", label: "Grade 11", icon: "psychology" },
|
||
{ id: "grade-12", label: "Grade 12", icon: "school" },
|
||
];
|
||
|
||
const subjectItems = [
|
||
{ id: "math", label: "Mathematics" },
|
||
{ id: "science", label: "Science" },
|
||
{ id: "history", label: "History" },
|
||
{ id: "language", label: "Language Arts" },
|
||
{ id: "english", label: "English" },
|
||
{ id: "geography", label: "Geography" },
|
||
];
|
||
|
||
const chapterData = {
|
||
math: [
|
||
{ id: "ch-1", label: "Chapter 1: Rational Numbers" },
|
||
{ id: "ch-2", label: "Chapter 2: Linear Equations" },
|
||
{ id: "ch-3", label: "Chapter 3: Coordinate Geometry" },
|
||
{ id: "ch-4", label: "Chapter 4: Algebra" },
|
||
{ id: "ch-5", label: "Chapter 5: Geometry" },
|
||
{ id: "ch-6", label: "Chapter 6: Trigonometry" },
|
||
],
|
||
science: [
|
||
{ id: "ch-1", label: "Chapter 1: Nutrition in Plants" },
|
||
{ id: "ch-2", label: "Chapter 2: Photosynthesis" },
|
||
{ id: "ch-3", label: "Chapter 3: Human Physiology" },
|
||
{ id: "ch-4", label: "Chapter 4: Chemistry Basics" },
|
||
{ id: "ch-5", label: "Chapter 5: Motion & Force" },
|
||
{ id: "ch-6", label: "Chapter 6: Electricity" },
|
||
],
|
||
history: [
|
||
{ id: "ch-1", label: "Chapter 1: Early Civilizations" },
|
||
{ id: "ch-2", label: "Chapter 2: Ancient India" },
|
||
{ id: "ch-3", label: "Chapter 3: Medieval Period" },
|
||
{ id: "ch-4", label: "Chapter 4: Modern India" },
|
||
{ id: "ch-5", label: "Chapter 5: World Wars" },
|
||
{ id: "ch-6", label: "Chapter 6: Independence Movement" },
|
||
],
|
||
language: [
|
||
{ id: "ch-1", label: "Chapter 1: Grammar Basics" },
|
||
{ id: "ch-2", label: "Chapter 2: Comprehension" },
|
||
{ id: "ch-3", label: "Chapter 3: Creative Writing" },
|
||
{ id: "ch-4", label: "Chapter 4: Literature" },
|
||
{ id: "ch-5", label: "Chapter 5: Vocabulary" },
|
||
{ id: "ch-6", label: "Chapter 6: Composition" },
|
||
],
|
||
english: [
|
||
{ id: "ch-1", label: "Chapter 1: Reading Skills" },
|
||
{ id: "ch-2", label: "Chapter 2: Writing Skills" },
|
||
{ id: "ch-3", label: "Chapter 3: Grammar" },
|
||
{ id: "ch-4", label: "Chapter 4: Poetry" },
|
||
{ id: "ch-5", label: "Chapter 5: Prose" },
|
||
{ id: "ch-6", label: "Chapter 6: Drama" },
|
||
],
|
||
geography: [
|
||
{ id: "ch-1", label: "Chapter 1: Earth & Universe" },
|
||
{ id: "ch-2", label: "Chapter 2: Landforms" },
|
||
{ id: "ch-3", label: "Chapter 3: Climate & Weather" },
|
||
{ id: "ch-4", label: "Chapter 4: Natural Resources" },
|
||
{ id: "ch-5", label: "Chapter 5: Maps & Atlas" },
|
||
{ id: "ch-6", label: "Chapter 6: Human Geography" },
|
||
],
|
||
};
|
||
|
||
function App() {
|
||
const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth();
|
||
const [messages, setMessages] = useState([]);
|
||
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
|
||
|
||
/* ─── Subject label resolution (ID → display name) ─── */
|
||
const getSubjectLabel = (id) => {
|
||
if (!id || id === "choose-subject") return "Choose Subject";
|
||
return subjectItems.find((s) => s.id === id)?.label || id;
|
||
};
|
||
|
||
/* ─── Chapter list resolution (subject ID → array) ─── */
|
||
const getChaptersForSubject = (subjectId) => {
|
||
return chapterData[subjectId] || [];
|
||
};
|
||
|
||
/* ─── Chapter label resolution (ID → display name) ─── */
|
||
const getChapterLabel = (chapterId) => {
|
||
if (!chapterId || chapterId === "choose-chapter") return "Choose Chapter";
|
||
return chapterId;
|
||
};
|
||
const [activeChat, setActiveChat] = useState(null);
|
||
const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
|
||
const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
|
||
const [isTyping, setIsTyping] = useState(false);
|
||
const [sessions, setSessions] = useState([]);
|
||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||
const [selectorStep, setSelectorStep] = useState("grade"); // grade → subject → chapter → chat
|
||
const abortRef = useRef(null);
|
||
const hasMountedRef = useRef(false); // track first mount to avoid resetting on initial load
|
||
|
||
// Reset all user-scoped state whenever authentication changes.
|
||
useEffect(() => {
|
||
if (!hasMountedRef.current) {
|
||
hasMountedRef.current = true;
|
||
if (isAuthenticated) loadSessions();
|
||
return;
|
||
}
|
||
|
||
if (abortRef.current) {
|
||
abortRef.current.abort();
|
||
abortRef.current = null;
|
||
}
|
||
|
||
const next = resetAppState();
|
||
setMessages(next.messages);
|
||
setActiveChat(next.activeChat);
|
||
setActiveGrade(next.activeGrade);
|
||
setActiveSubject(next.activeSubject);
|
||
setActiveChapter(next.activeChapter);
|
||
setIsTyping(next.isTyping);
|
||
setSelectorStep(next.selectorStep);
|
||
setSessions(next.sessions);
|
||
|
||
if (isAuthenticated) loadSessions();
|
||
}, [isAuthenticated]);
|
||
|
||
|
||
|
||
// ─── Auth handlers ───
|
||
const handleAuthError = (msg) => {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ id: Date.now(), role: "assistant", text: `⚠️ ${msg}` },
|
||
]);
|
||
};
|
||
|
||
const loadSessions = async () => {
|
||
setIsLoadingSessions(true);
|
||
try {
|
||
const token = await getToken();
|
||
const headers = {};
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${API_BASE}/api/sessions`, { headers, credentials: "include" });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setSessions(data);
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to load sessions:", err);
|
||
} finally {
|
||
setIsLoadingSessions(false);
|
||
}
|
||
};
|
||
|
||
const handleNewChat = () => {
|
||
// Abort any in-flight request
|
||
if (abortRef.current) {
|
||
abortRef.current.abort();
|
||
abortRef.current = null;
|
||
}
|
||
|
||
// Clear everything
|
||
setMessages([]);
|
||
setActiveChat(null);
|
||
setActiveGrade(DEFAULTS.grade);
|
||
setActiveSubject(DEFAULTS.subject);
|
||
setActiveChapter(DEFAULTS.chapter);
|
||
setIsTyping(false);
|
||
|
||
// Reset selector to grade step
|
||
setSelectorStep("grade");
|
||
|
||
// Refresh sessions list from backend
|
||
loadSessions();
|
||
};
|
||
|
||
const handleChatSelect = async (sessionId) => {
|
||
// Abort any in-flight request
|
||
if (abortRef.current) {
|
||
abortRef.current.abort();
|
||
abortRef.current = null;
|
||
}
|
||
|
||
setIsTyping(true);
|
||
setMessages([]);
|
||
|
||
try {
|
||
const token = await getToken();
|
||
const headers = {};
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { headers, credentials: "include" });
|
||
if (!res.ok) {
|
||
throw new Error("Session not found");
|
||
}
|
||
const data = await res.json();
|
||
|
||
// Set the session's context (grade, subject, chapter)
|
||
if (data.session) {
|
||
setActiveGrade(data.session.grade || DEFAULTS.grade);
|
||
setActiveSubject(data.session.subject || DEFAULTS.subject);
|
||
setActiveChapter(data.session.chapter || DEFAULTS.chapter);
|
||
}
|
||
|
||
// Move to chat view
|
||
setSelectorStep("chat");
|
||
|
||
// Load the messages
|
||
if (data.messages) {
|
||
setMessages(data.messages.map((m) => ({ ...m, streaming: false })));
|
||
}
|
||
|
||
setActiveChat(sessionId);
|
||
} catch (err) {
|
||
console.error("Failed to load session:", err);
|
||
setMessages([
|
||
{
|
||
id: Date.now(),
|
||
role: "assistant",
|
||
text: `⚠️ Could not load that chat. It may have been deleted.`,
|
||
},
|
||
]);
|
||
} finally {
|
||
setIsTyping(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteChat = async (sessionId) => {
|
||
try {
|
||
const token = await getToken();
|
||
const headers = {};
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, {
|
||
method: "DELETE",
|
||
headers,
|
||
credentials: "include",
|
||
});
|
||
if (res.ok) {
|
||
// If we were on the deleted chat, clear it
|
||
if (activeChat === sessionId) {
|
||
handleNewChat();
|
||
} else {
|
||
loadSessions();
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Failed to delete session:", err);
|
||
}
|
||
};
|
||
|
||
const handleSend = useCallback(
|
||
async (text) => {
|
||
if (!text.trim() || isTyping) return;
|
||
|
||
// Abort any previous in-flight request
|
||
if (abortRef.current) {
|
||
abortRef.current.abort();
|
||
}
|
||
|
||
// Add user message immediately
|
||
const userMsg = { id: Date.now(), role: "user", text };
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
setIsTyping(true);
|
||
|
||
const fetchChatId = activeChat;
|
||
|
||
// Build request body
|
||
const body = {
|
||
text: text.trim(),
|
||
chatId: fetchChatId,
|
||
grade: activeGrade !== DEFAULTS.grade ? activeGrade : undefined,
|
||
subject: activeSubject !== DEFAULTS.subject ? activeSubject : undefined,
|
||
chapter: activeChapter !== DEFAULTS.chapter ? activeChapter : undefined,
|
||
};
|
||
|
||
// Open SSE connection
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
|
||
const token = await getToken();
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
fetch(`${API_BASE}/api/chat`, {
|
||
method: "POST",
|
||
headers,
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) {
|
||
throw new Error(`Server error: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
let assistantText = "";
|
||
|
||
// Create placeholder assistant message
|
||
const assistantId = Date.now() + 1;
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ id: assistantId, role: "assistant", text: "", streaming: true },
|
||
]);
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() || "";
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith("data: ")) continue;
|
||
const jsonStr = line.slice(6);
|
||
if (jsonStr === "[DONE]") continue;
|
||
|
||
try {
|
||
const data = JSON.parse(jsonStr);
|
||
if (data.type === "chunk") {
|
||
assistantText += data.content;
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === assistantId
|
||
? { ...m, text: assistantText }
|
||
: m
|
||
)
|
||
);
|
||
} else if (data.type === "done") {
|
||
// Mark as non-streaming
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === assistantId
|
||
? { ...m, streaming: false }
|
||
: m
|
||
)
|
||
);
|
||
} else if (data.type === "error") {
|
||
setMessages((prev) =>
|
||
prev.map((m) =>
|
||
m.id === assistantId
|
||
? {
|
||
...m,
|
||
text: `⚠️ Error: ${data.message}`,
|
||
streaming: false,
|
||
}
|
||
: m
|
||
)
|
||
);
|
||
}
|
||
} catch {
|
||
// Skip malformed SSE frames
|
||
}
|
||
}
|
||
}
|
||
|
||
setIsTyping(false);
|
||
abortRef.current = null;
|
||
|
||
// Refresh sessions to update timestamps
|
||
loadSessions();
|
||
})
|
||
.catch((err) => {
|
||
if (err.name === "AbortError") return; // intentional abort
|
||
setIsTyping(false);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
id: Date.now() + 1,
|
||
role: "assistant",
|
||
text: `⚠️ Connection error: ${err.message}. Make sure the backend is running on port 3001.`,
|
||
},
|
||
]);
|
||
abortRef.current = null;
|
||
});
|
||
},
|
||
[isTyping, activeChat, activeGrade, activeSubject, activeChapter]
|
||
);
|
||
|
||
const handlePromptClick = useCallback(
|
||
(promptText) => {
|
||
handleSend(promptText);
|
||
},
|
||
[handleSend]
|
||
);
|
||
|
||
/* ─── Selector step handlers ─── */
|
||
|
||
const handleGradeSelect = (item) => {
|
||
setActiveGrade(item.label);
|
||
setSelectorStep("subject");
|
||
};
|
||
|
||
const handleSubjectSelect = (item) => {
|
||
setActiveSubject(item.id);
|
||
setActiveChapter(DEFAULTS.chapter);
|
||
setSelectorStep("chapter");
|
||
};
|
||
|
||
const handleChapterSelect = (item) => {
|
||
setActiveChapter(item.label);
|
||
setSelectorStep("chat");
|
||
};
|
||
|
||
/* ─── Selector step data ─── */
|
||
|
||
const selectorData = {
|
||
grade: {
|
||
title: "Select your standard",
|
||
subtitle: "Choose the grade you are studying in",
|
||
items: gradeItems,
|
||
selected: activeGrade,
|
||
onSelect: handleGradeSelect,
|
||
},
|
||
subject: {
|
||
title: "Select your subject",
|
||
subtitle: `Chose by grade – ${activeGrade}`,
|
||
items: subjectItems,
|
||
selectedValue: activeSubject,
|
||
onSelect: handleSubjectSelect,
|
||
compareField: "id",
|
||
},
|
||
chapter: {
|
||
title: "Select a chapter",
|
||
subtitle: `Subject – ${getSubjectLabel(activeSubject)}`,
|
||
items: getChaptersForSubject(activeSubject),
|
||
selectedValue: activeChapter,
|
||
onSelect: handleChapterSelect,
|
||
compareField: "label",
|
||
},
|
||
};
|
||
|
||
// ─── Not authenticated → show sign-in modal ───
|
||
if (!isAuthenticated) {
|
||
return (
|
||
<SignInModal
|
||
onSignIn={async (email, password) => {
|
||
try {
|
||
await signIn(email, password);
|
||
} catch (err) {
|
||
handleAuthError(err.message || "Sign in failed");
|
||
}
|
||
}}
|
||
onSignUp={async (email, password) => {
|
||
try {
|
||
await signUp(email, password);
|
||
} catch (err) {
|
||
handleAuthError(err.message || "Sign up failed");
|
||
}
|
||
}}
|
||
onError={handleAuthError}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<Sidebar
|
||
activeSubject={activeSubject}
|
||
onSubjectChange={(id) => {
|
||
setActiveSubject(id);
|
||
setActiveChapter(DEFAULTS.chapter);
|
||
if (selectorStep !== "chat") {
|
||
setSelectorStep("subject");
|
||
}
|
||
}}
|
||
activeChat={activeChat}
|
||
onChatSelect={handleChatSelect}
|
||
onNewChat={handleNewChat}
|
||
sessions={sessions}
|
||
onDeleteChat={handleDeleteChat}
|
||
isLoadingSessions={isLoadingSessions}
|
||
showUpgrade={false}
|
||
user={user}
|
||
onSignOut={signOut}
|
||
/>
|
||
|
||
<div className="page__main">
|
||
<TopNav
|
||
activeGrade={activeGrade}
|
||
onGradeChange={(g) => setActiveGrade(g)}
|
||
subjectItems={subjectItems}
|
||
chapterData={chapterData}
|
||
activeSubject={activeSubject}
|
||
activeChapter={activeChapter}
|
||
onChapterChange={(ch) => {
|
||
setActiveChapter(ch);
|
||
if (selectorStep !== "chat") {
|
||
setSelectorStep("chapter");
|
||
}
|
||
}}
|
||
onSubjectChange={(id) => {
|
||
setActiveSubject(id);
|
||
setActiveChapter(DEFAULTS.chapter);
|
||
if (selectorStep !== "chat") {
|
||
setSelectorStep("subject");
|
||
}
|
||
}}
|
||
user={user}
|
||
onSignOut={signOut}
|
||
/>
|
||
|
||
<div className="main-content">
|
||
<ChatHistory
|
||
messages={messages}
|
||
activeSubject={activeSubject}
|
||
activeGrade={activeGrade}
|
||
activeChapter={activeChapter}
|
||
onPromptClick={handlePromptClick}
|
||
isTyping={isTyping}
|
||
selectorStep={selectorStep}
|
||
selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null}
|
||
/>
|
||
</div>
|
||
|
||
<ChatInput
|
||
onSend={handleSend}
|
||
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
|
||
disabled={isTyping}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|