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 b785d488..f76eba70 100644 --- a/backend/db/supabase_schema.sql +++ b/backend/db/supabase_schema.sql @@ -10,11 +10,12 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT, streak_count INTEGER DEFAULT 0, last_active_date TEXT, - room_id TEXT, - created_at TIMESTAMPTZ DEFAULT now(), - google_id TEXT UNIQUE, - avatar_url TEXT, - auth_provider TEXT DEFAULT 'google' + room_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + google_id TEXT UNIQUE, + avatar_url TEXT, + 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..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.""" @@ -117,9 +126,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 +137,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 +153,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 +174,14 @@ def google_callback(code: str = Query(...), state: str = Query(None)): on_conflict="user_id", ) + if not is_approved: + return RedirectResponse(f"{FRONTEND_URL}/pending") + # 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/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts new file mode 100644 index 00000000..4796f8d3 --- /dev/null +++ b/frontend/src/app/api/auth/session/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { signSession, SESSION_MAX_AGE } from '@/lib/sessionToken'; + +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 }); + } + + // Verify with the backend that the user exists and is approved. + let approved = false; + 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(userId)}`, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + 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 token = await signSession(userId); + const response = NextResponse.json({ ok: true }); + response.cookies.set('sapling_session', token, { + 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: '/' }); + return response; +} diff --git a/frontend/src/app/pending/page.tsx b/frontend/src/app/pending/page.tsx new file mode 100644 index 00000000..34d3ab1b --- /dev/null +++ b/frontend/src/app/pending/page.tsx @@ -0,0 +1,78 @@ +'use client'; + +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() { + await signOut(); + 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..7d832906 100644 --- a/frontend/src/app/signin/callback/page.tsx +++ b/frontend/src/app/signin/callback/page.tsx @@ -7,18 +7,36 @@ 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'); 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('/'); + fetch('/api/auth/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId }), + }).then(res => { + if (res.ok) { + confirmApproved(); + router.replace('/dashboard'); + } else { + router.replace('/signin'); + } + }); } 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/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 9c00d9fc..85ced0c5 100644 --- a/frontend/src/context/UserContext.tsx +++ b/frontend/src/context/UserContext.tsx @@ -16,8 +16,10 @@ interface UserContextValue { * never fire with the hardcoded default user before we know the real one. */ userReady: boolean; isAuthenticated: boolean; + isApproved: boolean; setActiveUser: (id: string, name: string, avatar?: string) => void; - signOut: () => void; + confirmApproved: () => void; + signOut: () => Promise; } const UserContext = createContext({ @@ -27,8 +29,10 @@ const UserContext = createContext({ users: [], userReady: false, isAuthenticated: false, + isApproved: false, setActiveUser: () => {}, - signOut: () => {}, + confirmApproved: () => {}, + signOut: () => Promise.resolve(), }); export function UserProvider({ children }: { children: React.ReactNode }) { @@ -37,6 +41,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); @@ -82,17 +87,24 @@ export function UserProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '' })); }; - const signOut = () => { - setUserId(''); - setUserName(''); - setAvatarUrl(''); - setIsAuthenticated(false); - localStorage.removeItem('sapling_user'); + const confirmApproved = () => setIsApproved(true); + + 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( - () => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, setActiveUser, signOut }), - [userId, userName, avatarUrl, users, userReady, isAuthenticated] + () => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved, setActiveUser, confirmApproved, signOut }), + [userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved] ); return ( diff --git a/frontend/src/lib/sessionToken.ts b/frontend/src/lib/sessionToken.ts new file mode 100644 index 00000000..5129ea8d --- /dev/null +++ b/frontend/src/lib/sessionToken.ts @@ -0,0 +1,66 @@ +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 | 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 { + 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, + 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 } | 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 }; + } catch { + return null; + } +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 00000000..388f217e --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,63 @@ +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' +] + +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)) + if (!isProtected) return NextResponse.next() + + 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)) + } + + // 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() +} + +export const config = { + matcher: [ + '/dashboard/:path*', '/learn/:path*', '/study/:path*', + '/tree/:path*', '/flashcards/:path*', '/library/:path*', + '/calendar/:path*', '/social/:path*' + ] +}