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 ( +
+ Sapling +

+ 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 ( +
+ Sapling + + {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() }