Files
padhle/frontend/src/App.jsx
T
hahachait ff9da4d324 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
2026-08-19 09:41:40 -04:00

557 lines
17 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================
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;