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 (
+
+
+
+ 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 (
+
+
+
+ {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*'
+ ]
+}