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:
2026-08-19 09:41:40 -04:00
parent 93a35a7343
commit ff9da4d324
43 changed files with 4174 additions and 249 deletions
+252 -30
View File
@@ -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>