feat: auth, persistence, and AI integration
- 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
This commit is contained in:
+252
-30
@@ -8,37 +8,165 @@ 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",
|
||||
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 [showSelectorFlow, setShowSelectorFlow] = 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
|
||||
|
||||
// Load sessions on mount
|
||||
// Reset all user-scoped state whenever authentication changes.
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, []);
|
||||
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 res = await fetch(`${API_BASE}/api/sessions`);
|
||||
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);
|
||||
@@ -65,8 +193,8 @@ function App() {
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setIsTyping(false);
|
||||
|
||||
// Show the selector flow
|
||||
setShowSelectorFlow(true);
|
||||
// Reset selector to grade step
|
||||
setSelectorStep("grade");
|
||||
|
||||
// Refresh sessions list from backend
|
||||
loadSessions();
|
||||
@@ -83,7 +211,11 @@ function App() {
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`);
|
||||
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");
|
||||
}
|
||||
@@ -96,8 +228,8 @@ function App() {
|
||||
setActiveChapter(data.session.chapter || DEFAULTS.chapter);
|
||||
}
|
||||
|
||||
// Hide selector flow when loading an existing chat
|
||||
setShowSelectorFlow(false);
|
||||
// Move to chat view
|
||||
setSelectorStep("chat");
|
||||
|
||||
// Load the messages
|
||||
if (data.messages) {
|
||||
@@ -121,8 +253,14 @@ function App() {
|
||||
|
||||
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
|
||||
@@ -138,7 +276,7 @@ function App() {
|
||||
};
|
||||
|
||||
const handleSend = useCallback(
|
||||
(text) => {
|
||||
async (text) => {
|
||||
if (!text.trim() || isTyping) return;
|
||||
|
||||
// Abort any previous in-flight request
|
||||
@@ -166,9 +304,14 @@ function App() {
|
||||
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: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -271,39 +414,120 @@ function App() {
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const handleGradeChange = (gradeLabel) => {
|
||||
setActiveGrade(gradeLabel);
|
||||
/* ─── Selector step handlers ─── */
|
||||
|
||||
const handleGradeSelect = (item) => {
|
||||
setActiveGrade(item.label);
|
||||
setSelectorStep("subject");
|
||||
};
|
||||
|
||||
const handleSubjectChange = (subjectId) => {
|
||||
setActiveSubject(subjectId);
|
||||
const handleSubjectSelect = (item) => {
|
||||
setActiveSubject(item.id);
|
||||
setActiveChapter(DEFAULTS.chapter);
|
||||
setSelectorStep("chapter");
|
||||
};
|
||||
|
||||
const handleChapterChange = (chapterLabel) => {
|
||||
setActiveChapter(chapterLabel);
|
||||
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={handleSubjectChange}
|
||||
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={setActiveGrade}
|
||||
onGradeChange={(g) => setActiveGrade(g)}
|
||||
subjectItems={subjectItems}
|
||||
chapterData={chapterData}
|
||||
activeSubject={activeSubject}
|
||||
onSubjectChange={handleSubjectChange}
|
||||
activeChapter={activeChapter}
|
||||
onChapterChange={setActiveChapter}
|
||||
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">
|
||||
@@ -314,16 +538,14 @@ function App() {
|
||||
activeChapter={activeChapter}
|
||||
onPromptClick={handlePromptClick}
|
||||
isTyping={isTyping}
|
||||
showSelectorFlow={showSelectorFlow}
|
||||
onGradeChange={handleGradeChange}
|
||||
onSubjectChange={handleSubjectChange}
|
||||
onChapterChange={handleChapterChange}
|
||||
selectorStep={selectorStep}
|
||||
selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
placeholder={`Ask anything about ${activeGrade} ${activeSubject} – ${activeChapter}...`}
|
||||
placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} – ${getChapterLabel(activeChapter)}...`}
|
||||
disabled={isTyping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user