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>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const DEFAULT_APP_STATE = Object.freeze({
|
||||
messages: [],
|
||||
activeChat: null,
|
||||
activeGrade: "Choose standard",
|
||||
activeSubject: "choose-subject",
|
||||
activeChapter: "choose-chapter",
|
||||
isTyping: false,
|
||||
sessions: [],
|
||||
selectorStep: "grade",
|
||||
});
|
||||
|
||||
export function resetAppState() {
|
||||
return {
|
||||
...DEFAULT_APP_STATE,
|
||||
messages: [],
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
@@ -8,13 +8,16 @@ import "./ChatHistory.css";
|
||||
|
||||
const ChatHistory = ({
|
||||
messages,
|
||||
activeSubject,
|
||||
activeGrade,
|
||||
activeChapter,
|
||||
onPromptClick,
|
||||
isTyping,
|
||||
showSelectorFlow,
|
||||
onGradeChange,
|
||||
selectorStep,
|
||||
selectorData,
|
||||
}) => {
|
||||
const hasMessages = messages.length > 0;
|
||||
const isSelector = selectorStep !== "chat";
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
@@ -63,6 +66,7 @@ const ChatHistory = ({
|
||||
|
||||
const streamingMsg = messages.find((m) => m.streaming);
|
||||
|
||||
/* ─── Messages mode ─── */
|
||||
if (hasMessages) {
|
||||
return (
|
||||
<div className="chat-history">
|
||||
@@ -79,14 +83,23 @@ const ChatHistory = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (showSelectorFlow) {
|
||||
/* ─── Selector mode (grade → subject → chapter) ─── */
|
||||
if (isSelector && selectorData) {
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<SelectorFlow grade={activeGrade} onGradeChange={onGradeChange} />
|
||||
<SelectorFlow
|
||||
title={selectorData.title}
|
||||
subtitle={selectorData.subtitle}
|
||||
selected={selectorData.selected}
|
||||
items={selectorData.items}
|
||||
onSelect={selectorData.onSelect}
|
||||
step={selectorStep}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Welcome / suggestions mode ─── */
|
||||
return (
|
||||
<div className="chat-history">
|
||||
<div className="chat-history__focus">
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
/* Fade-in animation when step changes */
|
||||
@keyframes selectorFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.selector-flow__content {
|
||||
animation: selectorFadeIn 0.35s ease forwards;
|
||||
}
|
||||
|
||||
.selector-flow__content {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
@@ -50,11 +66,17 @@
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-on-surface);
|
||||
text-align: center;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.selector-flow__option:hover:not(.selector-flow__option--selected) {
|
||||
background-color: var(--color-surface-low);
|
||||
border-color: var(--color-outline);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.selector-flow__option:active:not(.selector-flow__option--selected) {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.selector-flow__option--selected {
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import "./SelectorFlow.css";
|
||||
|
||||
const grades = ["Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12"];
|
||||
|
||||
export default function SelectorFlow({ grade, onGradeChange }) {
|
||||
const handleClick = (g) => onGradeChange(g);
|
||||
|
||||
export default function SelectorFlow({
|
||||
title,
|
||||
subtitle,
|
||||
selectedValue,
|
||||
items,
|
||||
onSelect,
|
||||
step,
|
||||
compareField = "label",
|
||||
}) {
|
||||
return (
|
||||
<div className="selector-flow">
|
||||
<div className="selector-flow" key={step}>
|
||||
<div className="selector-flow__content">
|
||||
<h3 className="selector-flow__title">Select your standard</h3>
|
||||
<p className="selector-flow__subtitle">Choose the grade you are studying in</p>
|
||||
<h3 className="selector-flow__title">{title}</h3>
|
||||
<p className="selector-flow__subtitle">{subtitle}</p>
|
||||
<div className="selector-flow__options">
|
||||
{grades.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
className={`selector-flow__option${grade === g ? " selector-flow__option--selected" : ""}`}
|
||||
onClick={() => handleClick(g)}
|
||||
>
|
||||
<span className="selector-flow__icon">school</span>
|
||||
<span className="selector-flow__label">{g}</span>
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedValue === item[compareField];
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`selector-flow__option${isSelected ? " selector-flow__option--selected" : ""}`}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="material-symbols-outlined selector-flow__icon">{item.icon}</span>
|
||||
)}
|
||||
<span className="selector-flow__label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -365,6 +365,38 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* User Info */
|
||||
.sidebar__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar__user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__user-email {
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.sidebar__footer {
|
||||
display: flex;
|
||||
|
||||
@@ -13,6 +13,9 @@ const Sidebar = ({
|
||||
sessions,
|
||||
onDeleteChat,
|
||||
isLoadingSessions,
|
||||
showUpgrade = true,
|
||||
user,
|
||||
onSignOut,
|
||||
}) => {
|
||||
const subjects = [
|
||||
{ id: "math", name: "Mathematics", icon: "functions" },
|
||||
@@ -121,26 +124,32 @@ const Sidebar = ({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sidebar__footer">
|
||||
<a href="#settings" className="sidebar__footer-btn">
|
||||
<span className="material-symbols-outlined sidebar__footer-icon">settings</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<a href="#help" className="sidebar__footer-btn">
|
||||
<span className="material-symbols-outlined sidebar__footer-icon">help_outline</span>
|
||||
<span>Help & Support</span>
|
||||
</a>
|
||||
{user && (
|
||||
<div className="sidebar__user">
|
||||
<span className="sidebar__user-avatar">
|
||||
{user.email?.charAt(0).toUpperCase() || "U"}
|
||||
</span>
|
||||
<span className="sidebar__user-email">{user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
<button className="sidebar__footer-btn" onClick={onSignOut}>
|
||||
<span className="material-symbols-outlined sidebar__footer-icon">logout</span>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
|
||||
{/* Upgrade to Pro Card */}
|
||||
<div className="sidebar__upgrade">
|
||||
<div className="sidebar__upgrade-inner">
|
||||
<div className="sidebar__upgrade-header">
|
||||
<span className="sidebar__upgrade-icon">👑</span>
|
||||
<span className="sidebar__upgrade-title">Upgrade to Pro</span>
|
||||
{showUpgrade && (
|
||||
<div className="sidebar__upgrade">
|
||||
<div className="sidebar__upgrade-inner">
|
||||
<div className="sidebar__upgrade-header">
|
||||
<span className="sidebar__upgrade-icon">👑</span>
|
||||
<span className="sidebar__upgrade-title">Upgrade to Pro</span>
|
||||
</div>
|
||||
<p className="sidebar__upgrade-text">Unlock study guides, quizzes and more!</p>
|
||||
<span className="material-symbols-outlined sidebar__upgrade-arrow">chevron_right</span>
|
||||
</div>
|
||||
<p className="sidebar__upgrade-text">Unlock study guides, quizzes and more!</p>
|
||||
<span className="material-symbols-outlined sidebar__upgrade-arrow">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -184,7 +184,8 @@
|
||||
}
|
||||
|
||||
/* User Avatar */
|
||||
.topnav__avatar {
|
||||
.topnav__avatar,
|
||||
.topnav__avatar-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
@@ -197,8 +198,12 @@
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.topnav__avatar:hover {
|
||||
background-color: var(--color-surface-lowest);
|
||||
.topnav__avatar-btn:hover {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.topnav__avatar-btn:hover .topnav__avatar-initial {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.topnav__avatar-initial {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import "./TopNav.css";
|
||||
|
||||
const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange }) => {
|
||||
const TopNav = ({ subjectItems, chapterData, activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange, user, onSignOut }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(null);
|
||||
const gradeRef = useRef(null);
|
||||
const subjectRef = useRef(null);
|
||||
@@ -54,25 +54,9 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
|
||||
{ id: "Grade 12", label: "Grade 12" },
|
||||
];
|
||||
|
||||
const subjects = [
|
||||
{ id: "choose-subject", label: "Choose Subject" },
|
||||
{ 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 chapters = [
|
||||
{ id: "choose-chapter", label: "Choose Chapter" },
|
||||
{ id: "chapter1", label: "Chapter 1: Rational Numbers" },
|
||||
{ id: "chapter2", label: "Chapter 2: Linear Equations" },
|
||||
{ id: "chapter3", label: "Chapter 3: Coordinate Geometry" },
|
||||
{ id: "chapter4", label: "Chapter 4: Life Processes" },
|
||||
{ id: "chapter5", label: "Chapter 5: Photosynthesis" },
|
||||
{ id: "chapter6", label: "Chapter 6: Human Physiology" },
|
||||
];
|
||||
// Chapters are now generated dynamically from chapterData (see chapterOptions above)
|
||||
// Subjects now come from subjectItems prop
|
||||
// Static list removed — was not subject-aware.
|
||||
|
||||
const subjectIcons = {
|
||||
math: "calculate",
|
||||
@@ -83,6 +67,25 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
|
||||
geography: "public",
|
||||
};
|
||||
|
||||
/* ─── Resolve subject display (ID → label) ─── */
|
||||
const resolvedSubjectLabel = activeSubject === "choose-subject"
|
||||
? "Choose Subject"
|
||||
: subjectItems?.find((s) => s.id === activeSubject)?.label || activeSubject || "Choose Subject";
|
||||
|
||||
/* ─── Resolve chapter pill label ─── */
|
||||
const resolvedChapterLabel = activeChapter === "choose-chapter" || !activeChapter
|
||||
? "Choose Chapter"
|
||||
: activeChapter;
|
||||
|
||||
/* ─── Subject-aware chapter options (from chapterData prop) ─── */
|
||||
const chapterOptions = (() => {
|
||||
const chapters = chapterData?.[activeSubject] || [];
|
||||
return [
|
||||
{ id: "choose-chapter", label: "Choose Chapter" },
|
||||
...chapters,
|
||||
];
|
||||
})();
|
||||
|
||||
return (
|
||||
<header className="topnav">
|
||||
<div className="topnav__selectors">
|
||||
@@ -125,16 +128,12 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
|
||||
<span className={`material-symbols-outlined topnav__pill-icon`}>
|
||||
{activeSubject === "choose-subject" ? "science" : subjectIcons[activeSubject] || "science"}
|
||||
</span>
|
||||
<span className="topnav__pill-label">
|
||||
{activeSubject === "choose-subject"
|
||||
? "Choose Subject"
|
||||
: subjects.find((s) => s.id === activeSubject)?.label || activeSubject}
|
||||
</span>
|
||||
<span className="topnav__pill-label">{resolvedSubjectLabel}</span>
|
||||
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
|
||||
</button>
|
||||
{openDropdown === "subject" && (
|
||||
<ul className="topnav__dropdown">
|
||||
{subjects.map((subject) => (
|
||||
{subjectItems?.map((subject) => (
|
||||
<li key={subject.id}>
|
||||
<button
|
||||
className={`topnav__dropdown-item ${activeSubject === subject.id ? "topnav__dropdown-item--active" : ""}`}
|
||||
@@ -160,15 +159,15 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
|
||||
aria-expanded={openDropdown === "chapter"}
|
||||
>
|
||||
<span className="material-symbols-outlined topnav__pill-icon">menu_book</span>
|
||||
<span className="topnav__pill-label">{activeChapter}</span>
|
||||
<span className="topnav__pill-label">{resolvedChapterLabel}</span>
|
||||
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
|
||||
</button>
|
||||
{openDropdown === "chapter" && (
|
||||
<ul className="topnav__dropdown">
|
||||
{chapters.map((chapter) => (
|
||||
{chapterOptions.map((chapter) => (
|
||||
<li key={chapter.id}>
|
||||
<button
|
||||
className={`topnav__dropdown-item ${activeChapter === chapter.label ? "topnav__dropdown-item--active" : ""}`}
|
||||
className={`topnav__dropdown-item ${(activeChapter === chapter.label || activeChapter === "choose-chapter") ? "topnav__dropdown-item--active" : ""}`}
|
||||
onClick={() => selectOption("chapter", chapter.label)}
|
||||
>
|
||||
{chapter.label}
|
||||
@@ -191,10 +190,17 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
|
||||
<span className="material-symbols-outlined">notifications_none</span>
|
||||
</button>
|
||||
|
||||
{/* User Avatar */}
|
||||
<div className="topnav__avatar">
|
||||
<span className="topnav__avatar-initial">S</span>
|
||||
</div>
|
||||
{/* User Avatar — Sign out on click */}
|
||||
<button
|
||||
className="topnav__avatar-btn"
|
||||
onClick={onSignOut}
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
>
|
||||
<span className="topnav__avatar-initial">
|
||||
{user?.email?.charAt(0).toUpperCase() || "U"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* ──────────────────────────────────────────
|
||||
Sign-In Modal — padhle
|
||||
────────────────────────────────────────── */
|
||||
|
||||
.signin-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.signin-card {
|
||||
background: var(--color-surface-lowest, #fff);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.signin-title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-on-surface);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.signin-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.signin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.signin-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.signin-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-on-surface);
|
||||
background: var(--color-surface-lowest);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.signin-input:focus {
|
||||
border-color: var(--color-primary, #000);
|
||||
}
|
||||
|
||||
.signin-btn {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: var(--color-primary, #000);
|
||||
color: var(--color-on-primary, #fff);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.signin-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.signin-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.signin-footer {
|
||||
margin-top: 20px;
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signin-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #000);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.signin-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.signin-note {
|
||||
margin-top: 16px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-on-surface-variant);
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
import "./SignIn.css";
|
||||
|
||||
const SignInModal = ({ onSignIn, onSignUp, onError }) => {
|
||||
const [mode, setMode] = useState("signin");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "signin") {
|
||||
await onSignIn(email, password);
|
||||
} else {
|
||||
await onSignUp(email, password);
|
||||
}
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
} catch (err) {
|
||||
if (onError) onError(err.message || "Authentication failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="signin-overlay">
|
||||
<div className="signin-card">
|
||||
<h2 className="signin-title">
|
||||
{mode === "signin" ? "Sign in to padhle" : "Create your account"}
|
||||
</h2>
|
||||
<p className="signin-subtitle">
|
||||
{mode === "signin"
|
||||
? "Welcome back! Enter your credentials."
|
||||
: "Start learning with AI-powered tutoring."}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="signin-form">
|
||||
<label className="signin-label" htmlFor="signin-email">Email</label>
|
||||
<input
|
||||
className="signin-input"
|
||||
id="signin-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<label className="signin-label" htmlFor="signin-password">Password</label>
|
||||
<input
|
||||
className="signin-input"
|
||||
id="signin-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
||||
minLength={6}
|
||||
/>
|
||||
|
||||
<button className="signin-btn" type="submit" disabled={loading}>
|
||||
{loading ? "Please wait..." : mode === "signin" ? "Sign in" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="signin-footer">
|
||||
{mode === "signin" ? (
|
||||
<>
|
||||
Don't have an account?{" "}
|
||||
<button className="signin-link" type="button" onClick={() => setMode("signup")}>
|
||||
Sign up
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{" "}
|
||||
<button className="signin-link" type="button" onClick={() => setMode("signin")}>
|
||||
Sign in
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="signin-note">
|
||||
By continuing, you agree to padhle's Terms of Service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInModal;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { auth } from "./backendAuth";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
/**
|
||||
* SupabaseAuth — provides auth state and helper methods to the tree.
|
||||
*
|
||||
* All auth is handled server-side via httpOnly cookies.
|
||||
* The frontend never sees raw tokens.
|
||||
*/
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// Restore session on mount (from httpOnly cookie)
|
||||
useEffect(() => {
|
||||
auth.me().then(({ user }) => {
|
||||
setUser(user);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const signIn = async (email, password) => {
|
||||
const data = await auth.signin(email, password);
|
||||
setUser(data.user);
|
||||
return data;
|
||||
};
|
||||
|
||||
const signUp = async (email, password) => {
|
||||
const data = await auth.signup(email, password);
|
||||
setUser(data.user);
|
||||
return data;
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
await auth.signout();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const getToken = async () => {
|
||||
// Token lives in httpOnly cookie — not accessible to JavaScript
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
getToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Backend-auth utility.
|
||||
* All auth operations go through the backend.
|
||||
* Tokens live in httpOnly cookies — never exposed to JavaScript.
|
||||
*/
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
|
||||
|
||||
async function authRequest(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
credentials: "include", // ← sends cookies automatically
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
return { res, data };
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
/**
|
||||
* Sign up with email and password.
|
||||
* Backend sets httpOnly cookies with the JWT.
|
||||
*/
|
||||
async signup(email, password) {
|
||||
const { res, data } = await authRequest("/api/auth/signup", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Signup failed");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password.
|
||||
* Backend sets httpOnly cookies with the JWT.
|
||||
*/
|
||||
async signin(email, password) {
|
||||
const { res, data } = await authRequest("/api/auth/signin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Sign in failed");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out.
|
||||
* Backend revokes session and clears cookies.
|
||||
*/
|
||||
async signout() {
|
||||
await authRequest("/api/auth/signout", {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user from the httpOnly cookie.
|
||||
*/
|
||||
async me() {
|
||||
const { res, data } = await authRequest("/api/auth/me");
|
||||
|
||||
if (!res.ok) {
|
||||
return { user: null };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated.
|
||||
* Backend verifies the httpOnly cookie.
|
||||
*/
|
||||
async check() {
|
||||
const { user } = await auth.me();
|
||||
return !!user;
|
||||
},
|
||||
};
|
||||
@@ -5,10 +5,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import { AuthProvider } from "./lib/auth/SupabaseAuth.jsx";
|
||||
import "./styles/global.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DEFAULT_APP_STATE, resetAppState } from "../src/appState.js";
|
||||
|
||||
test("resetAppState clears user-specific conversation state", () => {
|
||||
assert.deepEqual(resetAppState(), DEFAULT_APP_STATE);
|
||||
});
|
||||
Reference in New Issue
Block a user