diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 4796f8d..813c10a 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -20,7 +20,7 @@ export async function POST(request: NextRequest) { const timeout = setTimeout(() => controller.abort(), 3000); let res: Response; try { - res = await fetch(`${API_URL}/auth/me?user_id=${encodeURIComponent(userId)}`, { signal: controller.signal }); + res = await fetch(`${API_URL}/api/auth/me?user_id=${encodeURIComponent(userId)}`, { signal: controller.signal }); } finally { clearTimeout(timeout); } diff --git a/frontend/src/app/signin/page.tsx b/frontend/src/app/signin/page.tsx index 03a8e33..5a8ec4e 100644 --- a/frontend/src/app/signin/page.tsx +++ b/frontend/src/app/signin/page.tsx @@ -1,15 +1,57 @@ 'use client'; -import { Suspense } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import Image from 'next/image'; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5000'; +const ERROR_COPY: Record = { + not_approved: 'Your account is pending approval.', + invalid_domain: 'Sign-in is limited to approved school accounts.', + google_not_configured: 'Google sign-in is not configured on the server.', +}; + function SignInInner() { + const router = useRouter(); const searchParams = useSearchParams(); const error = searchParams.get('error'); + useEffect(() => { + let cancelled = false; + const saved = typeof window !== 'undefined' ? localStorage.getItem('sapling_user') : null; + if (!saved) return; + try { + const { id } = JSON.parse(saved) as { id?: string }; + if (!id || typeof id !== 'string') return; + fetch('/api/auth/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: id }), + }) + .then(res => { + if (cancelled) return; + if (res.ok) { + router.replace('/dashboard'); + return; + } + if (res.status === 403) { + router.replace('/pending'); + } + }) + .catch(() => { + /* ignore fetch errors */ + }); + } catch { + /* ignore parse errors */ + } + return () => { + cancelled = true; + }; + }, [router]); + + const errorMessage = error ? (ERROR_COPY[error] ?? `Something went wrong (${error}).`) : null; + return (
Sapling - {error === 'not_approved' && ( -
- Your account is pending approval. -
- )} -
-

- Welcome back -

-

- Sign in to continue -

- - + {errorMessage ? ( + <> +

+ {errorMessage} +

+ +

+ Back to home +

+ + ) : ( +

+ Redirecting to sign in… +

+ )}
); @@ -107,7 +133,7 @@ function SignInInner() { export default function SignInPage() { return ( - }> + }> ); diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 388f217..effe375 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -9,24 +9,75 @@ const PROTECTED = [ const API_URL = process.env.NEXT_PUBLIC_API_URL +function googleAuthRedirect() { + if (!API_URL) return null + return new URL('/api/auth/google', API_URL).toString() +} + +function redirectToGoogleOrSignin(request: NextRequest) { + const g = googleAuthRedirect() + if (g) return NextResponse.redirect(g) + const u = new URL('/signin', request.url) + u.searchParams.set('error', 'google_not_configured') + return NextResponse.redirect(u) +} + +async function redirectIfSignedIn(request: NextRequest): Promise { + const token = request.cookies.get('sapling_session')?.value + if (!token) return null + const session = await verifySession(token) + if (!session) return null + if (!API_URL) return null + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 3000) + let res: Response + try { + res = await fetch( + `${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`, + { signal: controller.signal }, + ) + } finally { + clearTimeout(timeout) + } + if (!res.ok) return null + const data = await res.json() + const dest = data.is_approved === true ? '/dashboard' : '/pending' + return NextResponse.redirect(new URL(dest, request.url)) + } catch { + return null + } +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl + + if (pathname === '/signin' || pathname === '/signin/') { + const redirect = await redirectIfSignedIn(request) + if (redirect) return redirect + const hasError = request.nextUrl.searchParams.get('error') + if (hasError) { + return NextResponse.next() + } + return redirectToGoogleOrSignin(request) + } + 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)) + return redirectToGoogleOrSignin(request) } const session = await verifySession(token) if (!session) { - return NextResponse.redirect(new URL('/signin', request.url)) + return redirectToGoogleOrSignin(request) } // Re-check approval live so revocation takes effect immediately. if (!API_URL) { - return NextResponse.redirect(new URL('/signin', request.url)) + return redirectToGoogleOrSignin(request) } try { const controller = new AbortController() @@ -34,21 +85,21 @@ export async function middleware(request: NextRequest) { let res: Response try { res = await fetch( - `${API_URL}/auth/me?user_id=${encodeURIComponent(session.userId)}`, + `${API_URL}/api/auth/me?user_id=${encodeURIComponent(session.userId)}`, { signal: controller.signal }, ) } finally { clearTimeout(timeout) } if (!res.ok) { - return NextResponse.redirect(new URL('/signin', request.url)) + return redirectToGoogleOrSignin(request) } 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 redirectToGoogleOrSignin(request) } return NextResponse.next() @@ -56,6 +107,7 @@ export async function middleware(request: NextRequest) { export const config = { matcher: [ + '/signin', '/dashboard/:path*', '/learn/:path*', '/study/:path*', '/tree/:path*', '/flashcards/:path*', '/library/:path*', '/calendar/:path*', '/social/:path*'