Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
.env.local

# vercel
.vercel
Expand Down
30 changes: 30 additions & 0 deletions app/HomeWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client"

import { useSession } from "next-auth/react"
import { useRouter } from "next/navigation"
import { useEffect } from "react"

export default function HomeWrapper({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession()
const router = useRouter()

useEffect(() => {
if (status === "loading") return

if (session?.user?.needsPassword) {
router.replace("/set-password?email=" + session.user.email)
}
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not put user email in the redirect URL.

On Line 15, appending session.user.email to the query string exposes PII in logs/history/referrers. Redirect to /set-password without email and resolve identity from the authenticated session on that page/API.

Proposed patch
-    if (session?.user?.needsPassword) {
-      router.replace("/set-password?email=" + session.user.email)
-    }
+    if (session?.user?.needsPassword) {
+      router.replace("/set-password")
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (session?.user?.needsPassword) {
router.replace("/set-password?email=" + session.user.email)
}
if (session?.user?.needsPassword) {
router.replace("/set-password")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/HomeWrapper.tsx` around lines 14 - 16, The redirect currently leaks PII
by appending session.user.email in router.replace("/set-password?email=" +
session.user.email); instead change the redirect to
router.replace("/set-password") (or equivalent) so no email is included, and
update the /set-password page/API to read the authenticated user from the
session (using session?.user or your auth helper) to resolve identity and
prefill/validate without any email query parameter; remove any code that reads
the email from query string and rely on session?.user?.email instead.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Freny07 note

}, [session, status, router])

if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-sm text-muted">Loading...</p>
</div>
)
}

if (session?.user?.needsPassword) return null

return <>{children}</>
}
30 changes: 30 additions & 0 deletions app/access-denied/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client"

import Link from "next/link"

export default function AccessDeniedPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="mx-auto max-w-md rounded-xl border border-border bg-background p-8 text-center">
<h2 className="text-xl font-semibold">Access Denied</h2>
<p className="mt-3 text-sm text-muted">
Only <span className="font-medium text-foreground">@iiitl.ac.in</span> accounts are allowed to access this platform.
</p>
<div className="mt-6 flex flex-col gap-3">
<Link
href="/login"
className="inline-flex h-10 items-center justify-center rounded-md bg-brand px-6 text-sm font-semibold text-white hover:bg-brand-700"
>
Back to sign in
</Link>
<Link
href="/register"
className="inline-flex h-10 items-center justify-center rounded-md bg-brand px-6 text-sm font-semibold text-white hover:bg-brand-700"
>
Back to sign up
</Link>
</div>
</div>
</div>
)
}
3 changes: 3 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@


export { GET, POST } from "@/lib/auth"
59 changes: 59 additions & 0 deletions app/api/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server"
import { connectDB } from "@/lib/db"
import { PasswordResetToken, hashToken } from "@/models/PasswordResetToken"
import clientPromise from "@/lib/mongodb"
import { Resend } from "resend"
import crypto from "crypto"

const resend = new Resend(process.env.RESEND_API_KEY)

export async function POST(req: Request) {
try {
const { email } = await req.json()
const canonical = email?.trim().toLowerCase()

if (!canonical || !/@iiitl\.ac\.in$/i.test(canonical)) {
return NextResponse.json({ error: "Invalid email." }, { status: 400 })
}

const client = await clientPromise
const db = client.db()
const user = await db.collection("users").findOne({ email: canonical })

if (!user || !user.password) {
return NextResponse.json({ success: true })
}

await connectDB()

await PasswordResetToken.deleteOne({ email: canonical })

const rawToken = crypto.randomBytes(32).toString("hex")

await PasswordResetToken.create({
email: canonical,
tokenHash: hashToken(rawToken),
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
})

// Do NOT log resetUrl — it contains a bearer token that grants account access
const resetUrl = `${process.env.NEXTAUTH_URL}/reset-password?token=${rawToken}`

await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: canonical,
subject: "Reset your IIITL Alumni password",
html: `
<p>You requested a password reset.</p>
<p>Click the link below to set a new password. This link expires in 15 minutes.</p>
<a href="${resetUrl}">${resetUrl}</a>
<p>If you didn't request this, you can ignore this email.</p>
`,
})

return NextResponse.json({ success: true })
} catch (err) {
console.error("FORGOT PASSWORD ERROR:", err)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}
66 changes: 66 additions & 0 deletions app/api/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server"
import clientPromise from "@/lib/mongodb"
import bcrypt from "bcryptjs"
import { cookies } from "next/headers"
import { randomUUID } from "crypto"
import { logEvent } from "@/lib/logger"

export async function POST(req: Request) {
try {
const { email, password } = await req.json()

if (!email || !password) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 })
}

const canonical = email.trim().toLowerCase()

if (!/@iiitl\.ac\.in$/i.test(canonical)) {
await logEvent(canonical, "LOGIN_REJECTED_INVALID_DOMAIN")
return NextResponse.json({ error: "Invalid credentials." }, { status: 401 })
}

const client = await clientPromise
const db = client.db()

const user = await db.collection("users").findOne({ email: canonical })
if (!user || !user.password) {
return NextResponse.json({ error: "Invalid email or password." }, { status: 400 })
}

const isValid = await bcrypt.compare(password, user.password)
if (!isValid) {
return NextResponse.json({ error: "Invalid email or password." }, { status: 400 })
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const sessionToken = randomUUID()
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)

await db.collection("sessions").insertOne({
sessionToken,
userId: user._id,
expires,
})


const useSecureCookies = process.env.NODE_ENV === "production"
const cookieName = useSecureCookies
? "__Secure-next-auth.session-token"
: "next-auth.session-token"

const cookieStore = await cookies()
cookieStore.set(cookieName, sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: useSecureCookies,
path: "/",
expires,
})

return NextResponse.json({ success: true })

} catch (err: unknown) {
console.error("Login Error:", err)
return NextResponse.json({ error: "An internal error occurred. Please try again." }, { status: 500 })
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
34 changes: 34 additions & 0 deletions app/api/rate-limit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server"
import { checkRateLimit } from "@/lib/rateLimit"

export async function POST(req: Request) {
try {
const body = await req.json()
const { email } = body

if (!email || typeof email !== "string") {
return NextResponse.json(
{ allowed: false, error: "Invalid email format" },
{ status: 400 }
)
}

const isValidDomain = /@iiitl\.ac\.in$/i.test(email)
if (!isValidDomain) {
return NextResponse.json(
{ allowed: false, error: "Email must be a valid @iiitl.ac.in domain" },
{ status: 400 }
)
}

const result = await checkRateLimit(email)

return NextResponse.json(result)
} catch (err) {
console.error("Rate limit error:", err)
return NextResponse.json(
{ allowed: false, error: "Malformed request" },
{ status: 400 }
)
}
}
86 changes: 86 additions & 0 deletions app/api/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server"
import clientPromise from "@/lib/mongodb"
import bcrypt from "bcryptjs"
import { logEvent } from "@/lib/logger"

export async function POST(req: Request) {
try {
const body = await req.json()
const { name, email, branch, graduationYear, password } = body

if (!name || !email || !branch || !password) {
// Log missing-fields rejections so abuse patterns are visible
await logEvent(email ?? null, "REGISTER_REJECTED_MISSING_FIELDS")
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const canonical = email.trim().toLowerCase()

if (!/@iiitl\.ac\.in$/i.test(canonical)) {
await logEvent(canonical, "REGISTER_REJECTED_INVALID_DOMAIN")
return NextResponse.json(
{ error: "Only @iiitl.ac.in emails are allowed." },
{ status: 400 }
)
}

const client = await clientPromise
const db = client.db()

const existingUser = await db.collection("users").findOne({ email: canonical })
if (existingUser) {
await logEvent(canonical, "REGISTER_REJECTED_DUPLICATE")
if (!existingUser.password) {
return NextResponse.json(
{ error: "Account exists without a password. Please sign in via Magic Link or Google to set one." },
{ status: 400 }
)
}
return NextResponse.json(
{ error: "An account with this email already exists." },
{ status: 400 }
)
}

const hash = await bcrypt.hash(password, 10)

try {
await db.collection("users").insertOne({
name,
email: canonical,
branch,
graduationYear,
password: hash,
createdAt: new Date(),
})
} catch (err: unknown) {
// Handle the race where two concurrent requests both pass the findOne
// check above and one then hits a duplicate-key error on insert
if (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: number }).code === 11000
) {
await logEvent(canonical, "REGISTER_REJECTED_DUPLICATE")
return NextResponse.json(
{ error: "An account with this email already exists." },
{ status: 409 }
)
}
throw err
}

return NextResponse.json({ success: true })

} catch (err: unknown) {
console.error("Registration error:", err)
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
)
}
}
50 changes: 50 additions & 0 deletions app/api/reset-password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server"
import { connectDB } from "@/lib/db"
import { PasswordResetToken, hashToken } from "@/models/PasswordResetToken"
import clientPromise from "@/lib/mongodb"
import bcrypt from "bcryptjs"

export async function POST(req: Request) {
try {
const { token, password } = await req.json()

if (!token || !password) {
return NextResponse.json({ error: "Missing fields." }, { status: 400 })
}
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Enforce password policy in reset endpoint.

On Line 11, this route accepts any password value; that allows weaker passwords than registration rules. Add minimum-length validation (and ideally shared policy validation) before hashing.

Proposed patch
-    if (!token || !password) {
+    if (!token || !password) {
       return NextResponse.json({ error: "Missing fields." }, { status: 400 })
     }
+    if (typeof password !== "string" || password.length < 6) {
+      return NextResponse.json(
+        { error: "Password must be at least 6 characters long." },
+        { status: 400 }
+      )
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/reset-password/route.ts` around lines 11 - 13, The reset-password
route currently only checks presence of token and password in the handler and
allows any password value; add a password policy check before proceeding to
hashing by validating password length (e.g., enforce PASSWORD_MIN_LENGTH) and/or
reusing the shared validatePassword(password) utility if available; if the
password fails validation return NextResponse.json({ error: "Password does not
meet minimum requirements." }, { status: 400 }) and only continue with hashing
and token use (the token, password variables and the route handler) when
validation passes.


await connectDB()

// findOneAndDelete: atomically consume the token so two concurrent
// requests cannot both pass and write different passwords
const record = await PasswordResetToken.findOneAndDelete({
tokenHash: hashToken(token),
expiresAt: { $gt: new Date() },
})

if (!record) {
return NextResponse.json(
{ error: "Reset link is invalid or has expired." },
{ status: 400 }
)
}

const hashed = await bcrypt.hash(password, 10)

const client = await clientPromise
const db = client.db()

const result = await db
.collection("users")
.updateOne({ email: record.email }, { $set: { password: hashed } })

// Fail explicitly if no account matched — don't silently return 200
if (result.matchedCount === 0) {
return NextResponse.json({ error: "Account not found." }, { status: 404 })
}

return NextResponse.json({ success: true })
} catch (err) {
console.error("RESET PASSWORD ERROR:", err)
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
}
Loading