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>
+18
View File
@@ -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;
+25 -16
View File
@@ -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 &amp; 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>
);
+8 -3
View File
@@ -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 {
+39 -33
View File
@@ -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>
);
+119
View File
@@ -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;
}
+97
View File
@@ -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;
+64
View File
@@ -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;
}
+88
View File
@@ -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;
},
};
+4 -1
View File
@@ -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>
);
+7
View File
@@ -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);
});