From cb8b4ee93ec2399e1a59cbeedf3f937e727fa4fb Mon Sep 17 00:00:00 2001
From: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Date: Tue, 14 Apr 2026 18:53:32 -0400
Subject: [PATCH 1/5] adding approval gate for closed beta
---
backend/db/migration_add_is_approved.sql | 1 +
backend/db/supabase_schema.sql | 3 +-
backend/routes/auth.py | 11 ++-
frontend/src/app/pending/page.tsx | 89 +++++++++++++++++
frontend/src/app/signin/callback/page.tsx | 15 ++-
frontend/src/app/signin/page.tsx | 114 ++++++++++++++++++++++
frontend/src/context/UserContext.tsx | 18 ++--
frontend/src/middleware.ts | 32 ++++++
8 files changed, 271 insertions(+), 12 deletions(-)
create mode 100644 backend/db/migration_add_is_approved.sql
create mode 100644 frontend/src/app/pending/page.tsx
create mode 100644 frontend/src/app/signin/page.tsx
create mode 100644 frontend/src/middleware.ts
diff --git a/backend/db/migration_add_is_approved.sql b/backend/db/migration_add_is_approved.sql
new file mode 100644
index 00000000..5f05f270
--- /dev/null
+++ b/backend/db/migration_add_is_approved.sql
@@ -0,0 +1 @@
+ALTER TABLE public.users ADD COLUMN IF NOT EXISTS is_approved BOOLEAN NOT NULL DEFAULT false;
diff --git a/backend/db/supabase_schema.sql b/backend/db/supabase_schema.sql
index 8db0f34d..143aef4f 100644
--- a/backend/db/supabase_schema.sql
+++ b/backend/db/supabase_schema.sql
@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMPTZ DEFAULT now(),
google_id TEXT UNIQUE,
avatar_url TEXT,
- auth_provider TEXT DEFAULT 'google'
+ auth_provider TEXT DEFAULT 'google',
+ is_approved BOOLEAN NOT NULL DEFAULT false
);
CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id);
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index b25c1b65..d010142f 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -117,9 +117,10 @@ def google_callback(code: str = Query(...), state: str = Query(None)):
)
# Determine user_id: check if this Google ID already exists
- existing = table("users").select("id", filters={"google_id": f"eq.{google_id}"})
+ existing = table("users").select("id,is_approved", filters={"google_id": f"eq.{google_id}"})
if existing:
user_id = existing[0]["id"]
+ is_approved = existing[0]["is_approved"]
# Update name/avatar in case they changed
table("users").update(
{"name": name, "avatar_url": avatar_url, "email": email},
@@ -127,9 +128,10 @@ def google_callback(code: str = Query(...), state: str = Query(None)):
)
else:
# Check if a user with this email exists (migration from old system)
- email_match = table("users").select("id", filters={"email": f"eq.{email}"})
+ email_match = table("users").select("id,is_approved", filters={"email": f"eq.{email}"})
if email_match:
user_id = email_match[0]["id"]
+ is_approved = email_match[0]["is_approved"]
table("users").update(
{
"google_id": google_id,
@@ -142,6 +144,7 @@ def google_callback(code: str = Query(...), state: str = Query(None)):
else:
# Create new user
user_id = f"user_{google_id}"
+ is_approved = False
table("users").insert({
"id": user_id,
"name": name,
@@ -162,10 +165,14 @@ def google_callback(code: str = Query(...), state: str = Query(None)):
on_conflict="user_id",
)
+ if not is_approved:
+ return RedirectResponse(f"{FRONTEND_URL}/signin?error=not_approved")
+
# Redirect to frontend with user info
params = urlencode({
"user_id": user_id,
"name": name,
"avatar": avatar_url,
+ "is_approved": "true",
})
return RedirectResponse(f"{FRONTEND_URL}/signin/callback?{params}")
diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx
new file mode 100644
index 00000000..fa882f45
--- /dev/null
+++ b/frontend/src/app/pending/page.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Image from 'next/image';
+
+export default function PendingPage() {
+ const router = useRouter();
+
+ useEffect(() => {
+ const approved = document.cookie
+ .split('; ')
+ .find(row => row.startsWith('sapling_approved='))
+ ?.split('=')[1];
+ if (approved === '1') {
+ router.replace('/dashboard');
+ }
+ }, [router]);
+
+ function handleSignOut() {
+ localStorage.removeItem('sapling_user');
+ document.cookie = 'sapling_approved=; path=/; max-age=0; SameSite=Lax';
+ document.cookie = 'sapling_uid=; path=/; max-age=0; SameSite=Lax';
+ router.replace('/signin');
+ }
+
+ return (
+
+
+
+ You're on the waitlist
+
+
+ We'll reach out when your access is approved.
+
+
+
+ );
+}
diff --git a/frontend/src/app/signin/callback/page.tsx b/frontend/src/app/signin/callback/page.tsx
index ef936e3a..5cc2bc1f 100644
--- a/frontend/src/app/signin/callback/page.tsx
+++ b/frontend/src/app/signin/callback/page.tsx
@@ -13,12 +13,21 @@ function CallbackInner() {
const userId = searchParams.get('user_id');
const name = searchParams.get('name');
const avatar = searchParams.get('avatar');
+ const isApproved = searchParams.get('is_approved') === 'true';
+ const error = searchParams.get('error');
+
+ if (error === 'not_approved' || !isApproved) {
+ router.replace('/pending');
+ return;
+ }
if (userId && name) {
- setActiveUser(userId, name, avatar || '');
- router.replace('/');
+ setActiveUser(userId, name, avatar || '', true);
+ document.cookie = 'sapling_approved=1; path=/; max-age=2592000; SameSite=Lax';
+ document.cookie = `sapling_uid=${userId}; path=/; max-age=2592000; SameSite=Lax`;
+ router.replace('/dashboard');
} else {
- router.replace('/');
+ router.replace('/signin');
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
diff --git a/frontend/src/app/signin/page.tsx b/frontend/src/app/signin/page.tsx
new file mode 100644
index 00000000..03a8e332
--- /dev/null
+++ b/frontend/src/app/signin/page.tsx
@@ -0,0 +1,114 @@
+'use client';
+
+import { Suspense } from 'react';
+import { useSearchParams } from 'next/navigation';
+import Image from 'next/image';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000';
+
+function SignInInner() {
+ const searchParams = useSearchParams();
+ const error = searchParams.get('error');
+
+ return (
+
+
+
+ {error === 'not_approved' && (
+
+ Your account is pending approval.
+
+ )}
+
+
+
+ Welcome back
+
+
+ Sign in to continue
+
+
+
+
+
+ );
+}
+
+export default function SignInPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx
index 9c00d9fc..b10b38ba 100644
--- a/frontend/src/context/UserContext.tsx
+++ b/frontend/src/context/UserContext.tsx
@@ -16,7 +16,8 @@ interface UserContextValue {
* never fire with the hardcoded default user before we know the real one. */
userReady: boolean;
isAuthenticated: boolean;
- setActiveUser: (id: string, name: string, avatar?: string) => void;
+ isApproved: boolean;
+ setActiveUser: (id: string, name: string, avatar?: string, approved?: boolean) => void;
signOut: () => void;
}
@@ -27,6 +28,7 @@ const UserContext = createContext({
users: [],
userReady: false,
isAuthenticated: false,
+ isApproved: false,
setActiveUser: () => {},
signOut: () => {},
});
@@ -37,6 +39,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
const [avatarUrl, setAvatarUrl] = useState('');
const [users, setUsers] = useState([]);
const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const [isApproved, setIsApproved] = useState(false);
// Becomes true after localStorage is read — prevents pages from fetching
// data with the hardcoded default before the real saved user is known.
const [userReady, setUserReady] = useState(false);
@@ -46,11 +49,12 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
const saved = localStorage.getItem('sapling_user');
if (saved) {
try {
- const { id, name, avatar } = JSON.parse(saved);
+ const { id, name, avatar, isApproved: savedApproved } = JSON.parse(saved);
setUserId(id);
setUserName(name);
if (avatar) setAvatarUrl(avatar);
setIsAuthenticated(true);
+ setIsApproved(savedApproved === true);
} catch {}
}
setUserReady(true);
@@ -74,12 +78,13 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
.catch(() => {});
}, []);
- const setActiveUser = (id: string, name: string, avatar?: string) => {
+ const setActiveUser = (id: string, name: string, avatar?: string, approved?: boolean) => {
setUserId(id);
setUserName(name);
if (avatar) setAvatarUrl(avatar);
setIsAuthenticated(true);
- localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '' }));
+ setIsApproved(approved === true);
+ localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '', isApproved: approved === true }));
};
const signOut = () => {
@@ -87,12 +92,13 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
setUserName('');
setAvatarUrl('');
setIsAuthenticated(false);
+ setIsApproved(false);
localStorage.removeItem('sapling_user');
};
const value = useMemo(
- () => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, setActiveUser, signOut }),
- [userId, userName, avatarUrl, users, userReady, isAuthenticated]
+ () => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved, setActiveUser, signOut }),
+ [userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved]
);
return (
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
new file mode 100644
index 00000000..4cfad540
--- /dev/null
+++ b/frontend/src/middleware.ts
@@ -0,0 +1,32 @@
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+
+const PROTECTED = [
+ '/dashboard', '/learn', '/study', '/tree',
+ '/flashcards', '/library', '/calendar', '/social'
+]
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl
+ const isProtected = PROTECTED.some(p => pathname.startsWith(p))
+ if (!isProtected) return NextResponse.next()
+
+ const uid = request.cookies.get('sapling_uid')?.value
+ const approved = request.cookies.get('sapling_approved')?.value
+
+ if (!uid) {
+ return NextResponse.redirect(new URL('/signin', request.url))
+ }
+ if (approved !== '1') {
+ return NextResponse.redirect(new URL('/pending', request.url))
+ }
+ return NextResponse.next()
+}
+
+export const config = {
+ matcher: [
+ '/dashboard/:path*', '/learn/:path*', '/study/:path*',
+ '/tree/:path*', '/flashcards/:path*', '/library/:path*',
+ '/calendar/:path*', '/social/:path*'
+ ]
+}
From 3618e79a7a529d783b5c48849a3e8bc326f7718f Mon Sep 17 00:00:00 2001
From: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Date: Tue, 14 Apr 2026 19:15:12 -0400
Subject: [PATCH 2/5] hardening coderabbit issues
---
backend/routes/auth.py | 11 ++++-
frontend/src/app/api/auth/session/route.ts | 50 ++++++++++++++++++++++
frontend/src/app/signin/callback/page.tsx | 8 ++--
frontend/src/context/UserContext.tsx | 1 +
frontend/src/middleware.ts | 4 +-
5 files changed, 68 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/app/api/auth/session/route.ts
diff --git a/backend/routes/auth.py b/backend/routes/auth.py
index d010142f..f9f5d7f7 100644
--- a/backend/routes/auth.py
+++ b/backend/routes/auth.py
@@ -68,6 +68,15 @@ def _generate_pkce_pair():
return code_verifier, code_challenge
+@router.get("/me")
+def get_me(user_id: str = Query(...)):
+ """Return approval status for a given user_id (used by Next.js session API route)."""
+ user = table("users").select("id,is_approved", filters={"id": f"eq.{user_id}"})
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ return {"user_id": user_id, "is_approved": bool(user[0]["is_approved"])}
+
+
@router.get("/google")
def google_login():
"""Redirect to Google consent screen with identity + calendar scopes."""
@@ -166,7 +175,7 @@ def google_callback(code: str = Query(...), state: str = Query(None)):
)
if not is_approved:
- return RedirectResponse(f"{FRONTEND_URL}/signin?error=not_approved")
+ return RedirectResponse(f"{FRONTEND_URL}/pending")
# Redirect to frontend with user info
params = urlencode({
diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts
new file mode 100644
index 00000000..98307631
--- /dev/null
+++ b/frontend/src/app/api/auth/session/route.ts
@@ -0,0 +1,50 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
+const SESSION_MAX_AGE = 2592000; // 30 days
+
+export async function POST(request: NextRequest) {
+ const { userId } = await request.json();
+ if (!userId || typeof userId !== 'string') {
+ return NextResponse.json({ error: 'Missing userId' }, { status: 400 });
+ }
+
+ // Verify with the backend that the user exists and is approved.
+ let approved = false;
+ try {
+ const res = await fetch(`${API_URL}/auth/me?user_id=${encodeURIComponent(userId)}`);
+ if (!res.ok) {
+ return NextResponse.json({ error: 'User not found' }, { status: 401 });
+ }
+ const data = await res.json();
+ approved = data.is_approved === true;
+ } catch {
+ return NextResponse.json({ error: 'Backend unreachable' }, { status: 502 });
+ }
+
+ if (!approved) {
+ return NextResponse.json({ error: 'Not approved' }, { status: 403 });
+ }
+
+ const response = NextResponse.json({ ok: true });
+ response.cookies.set('sapling_session', userId, {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: SESSION_MAX_AGE,
+ });
+ response.cookies.set('sapling_approved', '1', {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: SESSION_MAX_AGE,
+ });
+ return response;
+}
+
+export async function DELETE() {
+ const response = NextResponse.json({ ok: true });
+ response.cookies.set('sapling_session', '', { httpOnly: true, maxAge: 0, path: '/' });
+ response.cookies.set('sapling_approved', '', { httpOnly: true, maxAge: 0, path: '/' });
+ return response;
+}
diff --git a/frontend/src/app/signin/callback/page.tsx b/frontend/src/app/signin/callback/page.tsx
index 5cc2bc1f..d55bc12c 100644
--- a/frontend/src/app/signin/callback/page.tsx
+++ b/frontend/src/app/signin/callback/page.tsx
@@ -23,9 +23,11 @@ function CallbackInner() {
if (userId && name) {
setActiveUser(userId, name, avatar || '', true);
- document.cookie = 'sapling_approved=1; path=/; max-age=2592000; SameSite=Lax';
- document.cookie = `sapling_uid=${userId}; path=/; max-age=2592000; SameSite=Lax`;
- router.replace('/dashboard');
+ fetch('/api/auth/session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userId }),
+ }).then(() => router.replace('/dashboard'));
} else {
router.replace('/signin');
}
diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx
index b10b38ba..a6328db9 100644
--- a/frontend/src/context/UserContext.tsx
+++ b/frontend/src/context/UserContext.tsx
@@ -94,6 +94,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
setIsAuthenticated(false);
setIsApproved(false);
localStorage.removeItem('sapling_user');
+ fetch('/api/auth/session', { method: 'DELETE' });
};
const value = useMemo(
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index 4cfad540..d16aa50c 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -11,10 +11,10 @@ export function middleware(request: NextRequest) {
const isProtected = PROTECTED.some(p => pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
- const uid = request.cookies.get('sapling_uid')?.value
+ const session = request.cookies.get('sapling_session')?.value
const approved = request.cookies.get('sapling_approved')?.value
- if (!uid) {
+ if (!session) {
return NextResponse.redirect(new URL('/signin', request.url))
}
if (approved !== '1') {
From 5d0704f234cfb56ccefe87f8551357d17d0424e3 Mon Sep 17 00:00:00 2001
From: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Date: Tue, 14 Apr 2026 20:30:59 -0400
Subject: [PATCH 3/5] more issue fixes
---
frontend/src/app/api/auth/session/route.ts | 12 +---
frontend/src/app/pending/page.tsx | 16 +-----
frontend/src/app/signin/callback/page.tsx | 13 ++++-
frontend/src/context/UserContext.tsx | 16 +++---
frontend/src/lib/sessionToken.ts | 65 ++++++++++++++++++++++
frontend/src/middleware.ts | 12 ++--
6 files changed, 97 insertions(+), 37 deletions(-)
create mode 100644 frontend/src/lib/sessionToken.ts
diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts
index 98307631..fda2aff1 100644
--- a/frontend/src/app/api/auth/session/route.ts
+++ b/frontend/src/app/api/auth/session/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
+import { signSession, SESSION_MAX_AGE } from '@/lib/sessionToken';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
-const SESSION_MAX_AGE = 2592000; // 30 days
export async function POST(request: NextRequest) {
const { userId } = await request.json();
@@ -26,14 +26,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Not approved' }, { status: 403 });
}
+ const token = await signSession(userId);
const response = NextResponse.json({ ok: true });
- response.cookies.set('sapling_session', userId, {
- httpOnly: true,
- sameSite: 'lax',
- path: '/',
- maxAge: SESSION_MAX_AGE,
- });
- response.cookies.set('sapling_approved', '1', {
+ response.cookies.set('sapling_session', token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
@@ -45,6 +40,5 @@ export async function POST(request: NextRequest) {
export async function DELETE() {
const response = NextResponse.json({ ok: true });
response.cookies.set('sapling_session', '', { httpOnly: true, maxAge: 0, path: '/' });
- response.cookies.set('sapling_approved', '', { httpOnly: true, maxAge: 0, path: '/' });
return response;
}
diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx
index fa882f45..6491f358 100644
--- a/frontend/src/app/pending/page.tsx
+++ b/frontend/src/app/pending/page.tsx
@@ -1,26 +1,14 @@
'use client';
-import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
export default function PendingPage() {
const router = useRouter();
- useEffect(() => {
- const approved = document.cookie
- .split('; ')
- .find(row => row.startsWith('sapling_approved='))
- ?.split('=')[1];
- if (approved === '1') {
- router.replace('/dashboard');
- }
- }, [router]);
-
- function handleSignOut() {
+ async function handleSignOut() {
localStorage.removeItem('sapling_user');
- document.cookie = 'sapling_approved=; path=/; max-age=0; SameSite=Lax';
- document.cookie = 'sapling_uid=; path=/; max-age=0; SameSite=Lax';
+ await fetch('/api/auth/session', { method: 'DELETE' });
router.replace('/signin');
}
diff --git a/frontend/src/app/signin/callback/page.tsx b/frontend/src/app/signin/callback/page.tsx
index d55bc12c..7d832906 100644
--- a/frontend/src/app/signin/callback/page.tsx
+++ b/frontend/src/app/signin/callback/page.tsx
@@ -7,7 +7,7 @@ import { useUser } from '@/context/UserContext';
function CallbackInner() {
const searchParams = useSearchParams();
const router = useRouter();
- const { setActiveUser } = useUser();
+ const { setActiveUser, confirmApproved } = useUser();
useEffect(() => {
const userId = searchParams.get('user_id');
@@ -22,12 +22,19 @@ function CallbackInner() {
}
if (userId && name) {
- setActiveUser(userId, name, avatar || '', true);
+ setActiveUser(userId, name, avatar || '');
fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
- }).then(() => router.replace('/dashboard'));
+ }).then(res => {
+ if (res.ok) {
+ confirmApproved();
+ router.replace('/dashboard');
+ } else {
+ router.replace('/signin');
+ }
+ });
} else {
router.replace('/signin');
}
diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx
index a6328db9..24303fe9 100644
--- a/frontend/src/context/UserContext.tsx
+++ b/frontend/src/context/UserContext.tsx
@@ -17,7 +17,8 @@ interface UserContextValue {
userReady: boolean;
isAuthenticated: boolean;
isApproved: boolean;
- setActiveUser: (id: string, name: string, avatar?: string, approved?: boolean) => void;
+ setActiveUser: (id: string, name: string, avatar?: string) => void;
+ confirmApproved: () => void;
signOut: () => void;
}
@@ -30,6 +31,7 @@ const UserContext = createContext({
isAuthenticated: false,
isApproved: false,
setActiveUser: () => {},
+ confirmApproved: () => {},
signOut: () => {},
});
@@ -49,12 +51,11 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
const saved = localStorage.getItem('sapling_user');
if (saved) {
try {
- const { id, name, avatar, isApproved: savedApproved } = JSON.parse(saved);
+ const { id, name, avatar } = JSON.parse(saved);
setUserId(id);
setUserName(name);
if (avatar) setAvatarUrl(avatar);
setIsAuthenticated(true);
- setIsApproved(savedApproved === true);
} catch {}
}
setUserReady(true);
@@ -78,15 +79,16 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
.catch(() => {});
}, []);
- const setActiveUser = (id: string, name: string, avatar?: string, approved?: boolean) => {
+ const setActiveUser = (id: string, name: string, avatar?: string) => {
setUserId(id);
setUserName(name);
if (avatar) setAvatarUrl(avatar);
setIsAuthenticated(true);
- setIsApproved(approved === true);
- localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '', isApproved: approved === true }));
+ localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '' }));
};
+ const confirmApproved = () => setIsApproved(true);
+
const signOut = () => {
setUserId('');
setUserName('');
@@ -98,7 +100,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
};
const value = useMemo(
- () => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved, setActiveUser, signOut }),
+ () => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved, setActiveUser, confirmApproved, signOut }),
[userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved]
);
diff --git a/frontend/src/lib/sessionToken.ts b/frontend/src/lib/sessionToken.ts
new file mode 100644
index 00000000..ee6a3398
--- /dev/null
+++ b/frontend/src/lib/sessionToken.ts
@@ -0,0 +1,65 @@
+export const SESSION_MAX_AGE = 2592000; // 30 days in seconds
+
+function getSecret(): string {
+ const secret = process.env.SESSION_SECRET;
+ if (!secret) throw new Error('SESSION_SECRET env var is not set');
+ return secret;
+}
+
+function toBase64Url(buf: ArrayBuffer): string {
+ const bytes = new Uint8Array(buf);
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
+}
+
+function fromBase64Url(str: string): Uint8Array {
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/');
+ const padding = '='.repeat((4 - (padded.length % 4)) % 4);
+ const binary = atob(padded + padding);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+async function importKey(): Promise {
+ const raw = new TextEncoder().encode(getSecret());
+ return crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
+}
+
+export async function signSession(userId: string): Promise {
+ const payload = JSON.stringify({
+ userId,
+ approved: true,
+ exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE,
+ });
+ const payloadB64 = toBase64Url(new TextEncoder().encode(payload));
+ const key = await importKey();
+ const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payloadB64));
+ return `${payloadB64}.${toBase64Url(sig)}`;
+}
+
+export async function verifySession(
+ token: string,
+): Promise<{ userId: string; approved: boolean } | null> {
+ const dot = token.lastIndexOf('.');
+ if (dot < 0) return null;
+ const payloadB64 = token.slice(0, dot);
+ const sigB64 = token.slice(dot + 1);
+ try {
+ const key = await importKey();
+ const valid = await crypto.subtle.verify(
+ 'HMAC',
+ key,
+ fromBase64Url(sigB64),
+ new TextEncoder().encode(payloadB64),
+ );
+ if (!valid) return null;
+ const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(payloadB64)));
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) return null;
+ if (typeof payload.userId !== 'string') return null;
+ return { userId: payload.userId, approved: payload.approved === true };
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index d16aa50c..d121b301 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -1,23 +1,27 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
+import { verifySession } from '@/lib/sessionToken'
const PROTECTED = [
'/dashboard', '/learn', '/study', '/tree',
'/flashcards', '/library', '/calendar', '/social'
]
-export function middleware(request: NextRequest) {
+export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = PROTECTED.some(p => pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
- const session = request.cookies.get('sapling_session')?.value
- const approved = request.cookies.get('sapling_approved')?.value
+ const token = request.cookies.get('sapling_session')?.value
+ if (!token) {
+ return NextResponse.redirect(new URL('/signin', request.url))
+ }
+ const session = await verifySession(token)
if (!session) {
return NextResponse.redirect(new URL('/signin', request.url))
}
- if (approved !== '1') {
+ if (!session.approved) {
return NextResponse.redirect(new URL('/pending', request.url))
}
return NextResponse.next()
From f8ed72891636ad00141da9de3b6efd7cc5ec9ac6 Mon Sep 17 00:00:00 2001
From: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Date: Tue, 14 Apr 2026 21:37:56 -0400
Subject: [PATCH 4/5] more fixes
---
frontend/src/app/api/auth/session/route.ts | 15 ++++++++++++--
frontend/src/app/pending/page.tsx | 7 +++++--
frontend/src/components/Navbar.tsx | 4 ++--
frontend/src/context/UserContext.tsx | 23 ++++++++++++----------
frontend/src/lib/sessionToken.ts | 8 +++++---
5 files changed, 38 insertions(+), 19 deletions(-)
diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts
index fda2aff1..4796f8d3 100644
--- a/frontend/src/app/api/auth/session/route.ts
+++ b/frontend/src/app/api/auth/session/route.ts
@@ -1,9 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { signSession, SESSION_MAX_AGE } from '@/lib/sessionToken';
-const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
+const API_URL = process.env.NEXT_PUBLIC_API_URL;
export async function POST(request: NextRequest) {
+ if (!API_URL) {
+ return NextResponse.json({ error: 'NEXT_PUBLIC_API_URL not configured' }, { status: 500 });
+ }
+
const { userId } = await request.json();
if (!userId || typeof userId !== 'string') {
return NextResponse.json({ error: 'Missing userId' }, { status: 400 });
@@ -12,7 +16,14 @@ export async function POST(request: NextRequest) {
// Verify with the backend that the user exists and is approved.
let approved = false;
try {
- const res = await fetch(`${API_URL}/auth/me?user_id=${encodeURIComponent(userId)}`);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ let res: Response;
+ try {
+ res = await fetch(`${API_URL}/auth/me?user_id=${encodeURIComponent(userId)}`, { signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
if (!res.ok) {
return NextResponse.json({ error: 'User not found' }, { status: 401 });
}
diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx
index 6491f358..8fbe8fa6 100644
--- a/frontend/src/app/pending/page.tsx
+++ b/frontend/src/app/pending/page.tsx
@@ -8,8 +8,11 @@ export default function PendingPage() {
async function handleSignOut() {
localStorage.removeItem('sapling_user');
- await fetch('/api/auth/session', { method: 'DELETE' });
- router.replace('/signin');
+ try {
+ await fetch('/api/auth/session', { method: 'DELETE' });
+ } finally {
+ router.replace('/signin');
+ }
}
return (
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index 6351313d..3cd42d56 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -89,10 +89,10 @@ export default function Navbar() {
}
};
- const handleSignOut = () => {
+ const handleSignOut = async () => {
setMenuOpen(false);
setMobileNavOpen(false);
- signOut();
+ await signOut();
router.push('/');
};
diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx
index 24303fe9..85ced0c5 100644
--- a/frontend/src/context/UserContext.tsx
+++ b/frontend/src/context/UserContext.tsx
@@ -19,7 +19,7 @@ interface UserContextValue {
isApproved: boolean;
setActiveUser: (id: string, name: string, avatar?: string) => void;
confirmApproved: () => void;
- signOut: () => void;
+ signOut: () => Promise;
}
const UserContext = createContext({
@@ -32,7 +32,7 @@ const UserContext = createContext({
isApproved: false,
setActiveUser: () => {},
confirmApproved: () => {},
- signOut: () => {},
+ signOut: () => Promise.resolve(),
});
export function UserProvider({ children }: { children: React.ReactNode }) {
@@ -89,14 +89,17 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
const confirmApproved = () => setIsApproved(true);
- const signOut = () => {
- setUserId('');
- setUserName('');
- setAvatarUrl('');
- setIsAuthenticated(false);
- setIsApproved(false);
- localStorage.removeItem('sapling_user');
- fetch('/api/auth/session', { method: 'DELETE' });
+ const signOut = async () => {
+ try {
+ await fetch('/api/auth/session', { method: 'DELETE' });
+ } finally {
+ setUserId('');
+ setUserName('');
+ setAvatarUrl('');
+ setIsAuthenticated(false);
+ setIsApproved(false);
+ localStorage.removeItem('sapling_user');
+ }
};
const value = useMemo(
diff --git a/frontend/src/lib/sessionToken.ts b/frontend/src/lib/sessionToken.ts
index ee6a3398..ca92f575 100644
--- a/frontend/src/lib/sessionToken.ts
+++ b/frontend/src/lib/sessionToken.ts
@@ -3,17 +3,19 @@ export const SESSION_MAX_AGE = 2592000; // 30 days in seconds
function getSecret(): string {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET env var is not set');
+ if (new TextEncoder().encode(secret).byteLength < 32)
+ throw new Error('SESSION_SECRET must be at least 32 bytes long');
return secret;
}
-function toBase64Url(buf: ArrayBuffer): string {
- const bytes = new Uint8Array(buf);
+function toBase64Url(buf: ArrayBuffer | Uint8Array): string {
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
-function fromBase64Url(str: string): Uint8Array {
+function fromBase64Url(str: string): Uint8Array {
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (padded.length % 4)) % 4);
const binary = atob(padded + padding);
From 70d2dd29c03ae5c99fcf5432fdf4c6f43ac2db26 Mon Sep 17 00:00:00 2001
From: AndresL230 <190146319+AndresL230@users.noreply.github.com>
Date: Tue, 14 Apr 2026 21:50:27 -0400
Subject: [PATCH 5/5] more fixes
---
frontend/src/app/pending/page.tsx | 10 ++++------
frontend/src/lib/sessionToken.ts | 5 ++---
frontend/src/middleware.ts | 31 +++++++++++++++++++++++++++++--
3 files changed, 35 insertions(+), 11 deletions(-)
diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx
index 8fbe8fa6..34d3ab1b 100644
--- a/frontend/src/app/pending/page.tsx
+++ b/frontend/src/app/pending/page.tsx
@@ -2,17 +2,15 @@
import { useRouter } from 'next/navigation';
import Image from 'next/image';
+import { useUser } from '@/context/UserContext';
export default function PendingPage() {
const router = useRouter();
+ const { signOut } = useUser();
async function handleSignOut() {
- localStorage.removeItem('sapling_user');
- try {
- await fetch('/api/auth/session', { method: 'DELETE' });
- } finally {
- router.replace('/signin');
- }
+ await signOut();
+ router.replace('/signin');
}
return (
diff --git a/frontend/src/lib/sessionToken.ts b/frontend/src/lib/sessionToken.ts
index ca92f575..5129ea8d 100644
--- a/frontend/src/lib/sessionToken.ts
+++ b/frontend/src/lib/sessionToken.ts
@@ -32,7 +32,6 @@ async function importKey(): Promise {
export async function signSession(userId: string): Promise {
const payload = JSON.stringify({
userId,
- approved: true,
exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE,
});
const payloadB64 = toBase64Url(new TextEncoder().encode(payload));
@@ -43,7 +42,7 @@ export async function signSession(userId: string): Promise {
export async function verifySession(
token: string,
-): Promise<{ userId: string; approved: boolean } | null> {
+): Promise<{ userId: string } | null> {
const dot = token.lastIndexOf('.');
if (dot < 0) return null;
const payloadB64 = token.slice(0, dot);
@@ -60,7 +59,7 @@ export async function verifySession(
const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(payloadB64)));
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) return null;
if (typeof payload.userId !== 'string') return null;
- return { userId: payload.userId, approved: payload.approved === true };
+ return { userId: payload.userId };
} catch {
return null;
}
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index d121b301..388f217e 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -7,6 +7,8 @@ const PROTECTED = [
'/flashcards', '/library', '/calendar', '/social'
]
+const API_URL = process.env.NEXT_PUBLIC_API_URL
+
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = PROTECTED.some(p => pathname.startsWith(p))
@@ -21,9 +23,34 @@ export async function middleware(request: NextRequest) {
if (!session) {
return NextResponse.redirect(new URL('/signin', request.url))
}
- if (!session.approved) {
- return NextResponse.redirect(new URL('/pending', request.url))
+
+ // Re-check approval live so revocation takes effect immediately.
+ if (!API_URL) {
+ return NextResponse.redirect(new URL('/signin', request.url))
+ }
+ try {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), 3000)
+ let res: Response
+ try {
+ res = await fetch(
+ `${API_URL}/auth/me?user_id=${encodeURIComponent(session.userId)}`,
+ { signal: controller.signal },
+ )
+ } finally {
+ clearTimeout(timeout)
+ }
+ if (!res.ok) {
+ return NextResponse.redirect(new URL('/signin', request.url))
+ }
+ const data = await res.json()
+ if (data.is_approved !== true) {
+ return NextResponse.redirect(new URL('/pending', request.url))
+ }
+ } catch {
+ return NextResponse.redirect(new URL('/signin', request.url))
}
+
return NextResponse.next()
}