Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/app/api/auth/session/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
172 changes: 99 additions & 73 deletions frontend/src/app/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,113 +1,139 @@
'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<string, string> = {
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 */
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return () => {
cancelled = true;
};
}, [router]);

const errorMessage = error ? (ERROR_COPY[error] ?? `Something went wrong (${error}).`) : null;

return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-base, #0d1117)',
background: '#f0f5f0',
padding: '24px',
}}>
<Image
src="/sapling-word-icon.png"
alt="Sapling"
width={140}
height={40}
style={{ marginBottom: '40px', objectFit: 'contain' }}
style={{ marginBottom: '32px', objectFit: 'contain' }}
/>

{error === 'not_approved' && (
<div style={{
marginBottom: '24px',
padding: '12px 20px',
borderRadius: '8px',
background: 'rgba(234, 179, 8, 0.12)',
border: '1px solid rgba(234, 179, 8, 0.3)',
color: '#ca8a04',
fontSize: '14px',
textAlign: 'center',
maxWidth: '360px',
}}>
Your account is pending approval.
</div>
)}

<div style={{
background: 'var(--bg-panel, #161b22)',
border: '1px solid rgba(255,255,255,0.08)',
background: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '16px',
padding: '40px',
padding: '32px',
width: '100%',
maxWidth: '380px',
maxWidth: '400px',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
}}>
<h2 style={{
fontSize: '22px',
fontWeight: 600,
color: 'var(--brand-text1, #e6edf3)',
textAlign: 'center',
marginBottom: '8px',
letterSpacing: '-0.02em',
}}>
Welcome back
</h2>
<p style={{
fontSize: '14px',
color: 'var(--brand-text2, #8b949e)',
textAlign: 'center',
marginBottom: '28px',
}}>
Sign in to continue
</p>

<button
onClick={() => { window.location.href = `${API_URL}/api/auth/google`; }}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '12px 20px',
background: '#ffffff',
border: '1px solid #d1d5db',
borderRadius: '10px',
fontSize: '14px',
fontWeight: 500,
color: '#111827',
cursor: 'pointer',
transition: 'box-shadow 0.2s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.12)'; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.boxShadow = 'none'; }}
>
<svg width="18" height="18" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Continue with Google
</button>
{errorMessage ? (
<>
<p style={{
fontSize: '15px',
color: '#374151',
textAlign: 'center',
lineHeight: 1.5,
marginBottom: '24px',
}}>
{errorMessage}
</p>
<button
type="button"
onClick={() => { window.location.href = `${API_URL}/api/auth/google`; }}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '12px 20px',
background: '#ffffff',
border: '1px solid #d1d5db',
borderRadius: '10px',
fontSize: '14px',
fontWeight: 500,
color: '#111827',
cursor: 'pointer',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden>
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
Try again with Google
</button>
<p style={{ marginTop: '16px', textAlign: 'center', fontSize: '13px', color: '#6b7280' }}>
<a href="/" style={{ color: '#1B6C42', fontWeight: 500 }}>Back to home</a>
</p>
</>
) : (
<p style={{ fontSize: '14px', color: '#6b7280', textAlign: 'center' }}>
Redirecting to sign in…
</p>
)}
</div>
</div>
);
}

export default function SignInPage() {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#0d1117' }} />}>
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f0f5f0' }} />}>
<SignInInner />
</Suspense>
);
Expand Down
64 changes: 58 additions & 6 deletions frontend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,53 +9,105 @@ 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<NextResponse | null> {
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()
const timeout = setTimeout(() => controller.abort(), 3000)
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()
}

export const config = {
matcher: [
'/signin',
'/dashboard/:path*', '/learn/:path*', '/study/:path*',
'/tree/:path*', '/flashcards/:path*', '/library/:path*',
'/calendar/:path*', '/social/:path*'
Expand Down
Loading