diff --git a/.env.example b/.env.example
index 0b69b12..02f5ab1 100644
--- a/.env.example
+++ b/.env.example
@@ -13,3 +13,11 @@ MAILGUN_API_KEY=""
MAILGUN_DOMAIN=""
# MAILGUN_URL="https://api.eu.mailgun.net" # Uncomment if using an EU region domain
EMAIL_FROM="no-reply@yourdomain.com"
+
+# Auth
+AUTH_SECRET=""
+AUTH_URL=""
+
+# Google
+GOOGLE_CLIENT_ID="your-client-id-here.apps.googleusercontent.com"
+GOOGLE_CLIENT_SECRET="your-client-secret-here"
\ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..9879351
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "@/auth"
+
+export const { GET, POST } = handlers
diff --git a/app/api/verify-email/route.ts b/app/api/verify-email/route.ts
new file mode 100644
index 0000000..c3c9b9d
--- /dev/null
+++ b/app/api/verify-email/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from "next/server";
+import clientPromise from "@/lib/mongodb";
+import { redisClient } from "@/lib/ratelimit";
+
+export async function GET(req: NextRequest) {
+ const token = req.nextUrl.searchParams.get("token");
+
+ if (!token || !redisClient) {
+ return NextResponse.redirect(new URL("/login?error=InvalidToken", req.url));
+ }
+
+ // 1. Retrieve pending data from Redis
+ const rawData = await redisClient.get(`verify_${token}`);
+ if (!rawData) {
+ return NextResponse.redirect(new URL("/login?error=TokenExpired", req.url));
+ }
+
+ const pendingUser = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+
+ // 2. Atomic Save to MongoDB
+ const db = (await clientPromise).db();
+
+ // Atomic upsert — safe under concurrent clicks / retries.
+ await db.collection("users").updateOne(
+ { email: pendingUser.email },
+ {
+ $setOnInsert: {
+ ...pendingUser,
+ emailVerified: new Date(),
+ createdAt: new Date(),
+ },
+ },
+ { upsert: true }
+ );
+
+ // 3. Delete the token from Redis
+ await redisClient.del(`verify_${token}`);
+
+ // 4. Redirect them to login
+ return NextResponse.redirect(new URL("/login?success=EmailVerified", req.url));
+}
\ No newline at end of file
diff --git a/app/globals.css b/app/globals.css
index 5c17458..cffa2cc 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -15,6 +15,7 @@
@theme inline {
--color-background: var(--background);
+ --color-background-700: var(--background-700);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-surface: var(--surface);
@@ -32,6 +33,7 @@
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0f1c;
+ --background-700: #1d2846;
--foreground: #e8ecf5;
--muted: #97a1b5;
--surface: #111729;
diff --git a/app/login/actions.ts b/app/login/actions.ts
new file mode 100644
index 0000000..e671119
--- /dev/null
+++ b/app/login/actions.ts
@@ -0,0 +1,64 @@
+"use server"
+
+import { signIn } from "@/auth"
+import { AuthError } from "next-auth"
+import { redirect } from "next/navigation"
+import { limit } from "@/lib/ratelimit"
+
+export async function signInWithMagicLink(formData: FormData) {
+ const email = formData.get("email") as string;
+
+ if (!email || !/@iiitl\.ac\.in$/i.test(email)) {
+ redirect("/login?error=AccessDenied")
+ }
+
+ // Pre-check rate limits before calling Auth.js
+ const { success } = await limit(`magic_link_${email}`, {
+ maxRequests: 5,
+ window: '1h'
+ });
+
+ if (!success) {
+ redirect("/login?error=AccessDenied")
+ }
+
+ try {
+ await signIn("email", { email, redirectTo: "/" })
+ } catch (error) {
+ if (error instanceof AuthError) {
+ redirect("/login?error=AccessDenied")
+ }
+ throw error
+ }
+}
+
+export async function signInWithCredentials(formData: FormData) {
+ const email = formData.get("email") as string;
+ const password = formData.get("password") as string;
+
+ if (!email || !password || !/@iiitl\.ac\.in$/i.test(email)) {
+ redirect("/login?error=InvalidCredentials")
+ }
+
+ try {
+ await signIn("credentials", { email, password, redirectTo: "/" })
+ } catch (error) {
+ if (error instanceof AuthError) {
+ // AuthError translates to invalid credentials
+ redirect("/login?error=InvalidCredentials")
+ }
+ throw error
+ }
+}
+
+export async function signInWithGoogle() {
+ try {
+ await signIn("google", { redirectTo: "/" })
+ } catch (error) {
+ if (error instanceof AuthError) {
+ redirect("/login?error=AccessDenied")
+ }
+ throw error
+ }
+}
+
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 52a6147..3e30869 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { Section } from "@/components/Section";
+import { signInWithCredentials, signInWithMagicLink, signInWithGoogle } from "./actions";
export const metadata = { title: "Sign in" };
@@ -11,27 +12,53 @@ export default function LoginPage() {
Sign in to access the alumni directory, events, and the job board.
-
New to IIITL Alumni?{" "}
diff --git a/app/register/RegistrationForm.tsx b/app/register/RegistrationForm.tsx
new file mode 100644
index 0000000..d25de94
--- /dev/null
+++ b/app/register/RegistrationForm.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+// app/register/RegisterForm.tsx
+import { useActionState } from "react";
+import { handleRegistration, signUpWithGoogle } from "@/app/register/actions";
+
+export default function RegisterForm() {
+ // useActionState takes your action and an initial state object
+ const [state, formAction, isPending] = useActionState(handleRegistration, null);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/app/register/actions.ts b/app/register/actions.ts
new file mode 100644
index 0000000..64d4ef5
--- /dev/null
+++ b/app/register/actions.ts
@@ -0,0 +1,109 @@
+"use server"
+
+import { signIn } from "@/auth"
+import { AuthError } from "next-auth"
+import { redirect } from "next/navigation"
+import { headers } from "next/headers"
+import bcrypt from "bcryptjs";
+import crypto from "crypto";
+import { redisClient, limit } from "@/lib/ratelimit";
+import { sendEmail } from "@/lib/email";
+import clientPromise from "@/lib/mongodb";
+
+export async function handleRegistration(prevState: unknown, formData: FormData) {
+ try {
+ const authUrl = process.env.AUTH_URL;
+ if (!authUrl) throw new Error("Server configuration error: AUTH_URL is missing.");
+
+ const rawEmail = formData.get("email");
+ const rawPassword = formData.get("password");
+ const rawBranch = formData.get("branch");
+ const rawGradYear = formData.get("graduationYear");
+ const rawName = formData.get("name");
+
+ // 1. Explicitly narrow formData values to strings and reject empty/files
+ if (typeof rawEmail !== "string" || !rawEmail.trim()) throw new Error("Invalid email");
+ if (typeof rawPassword !== "string") throw new Error("Invalid password");
+ if (typeof rawBranch !== "string" || !rawBranch.trim()) throw new Error("Invalid branch");
+ if (typeof rawGradYear !== "string" || !rawGradYear.trim()) throw new Error("Invalid graduation year");
+ if (typeof rawName !== "string" || !rawName.trim()) throw new Error("Invalid name");
+
+ const email = rawEmail.trim();
+ const password = rawPassword;
+ const branch = rawBranch.trim();
+ const graduationYearStr = rawGradYear.trim();
+ const name = rawName.trim();
+
+ if (!/@iiitl\.ac\.in$/i.test(email)) throw new Error("Invalid domain. Must end with @iiitl.ac.in");
+
+ // 2. Validate password complexity and graduation year
+ if (password.length < 8) throw new Error("Password must be at least 8 characters long.");
+
+ const yearParsed = parseInt(graduationYearStr, 10);
+ const currentYear = new Date().getFullYear();
+ // Assuming IIITL started recently, graduation years from ~2018 to few years ahead
+ if (isNaN(yearParsed) || yearParsed < 2018 || yearParsed > currentYear + 8) {
+ throw new Error("Invalid graduation year.");
+ }
+
+ // 4. Rate Limiting by IP and Email
+ const headerList = await headers();
+ const ip = headerList.get("x-forwarded-for") || "unknown_ip";
+
+ // Throttle by IP (prevent spam bots)
+ const ipLimit = await limit(`verify_ip_${ip}`, { maxRequests: 5, window: "1h" });
+ if (!ipLimit.success) throw new Error("Too many registration attempts. Please try again later.");
+
+ // Throttle by Email (prevent inbox bombing)
+ const emailLimit = await limit(`verify_email_${email}`, { maxRequests: 3, window: "1h" });
+ if (!emailLimit.success) throw new Error("Verification emails are being sent too quickly. Please check your inbox or wait an hour.");
+
+ // 3. Check existing user in MongoDB
+ const db = (await clientPromise).db();
+ const existingUser = await db.collection("users").findOne({ email });
+ if (existingUser) {
+ throw new Error("An account is already registered with this email. Please sign in instead.");
+ }
+
+ // 5. Hash the password and create pending user
+ const hashedPassword = await bcrypt.hash(password, 10);
+ const token = crypto.randomUUID();
+
+ const pendingUser = {
+ email,
+ name,
+ branch,
+ graduationYear: yearParsed,
+ hashedPassword,
+ };
+
+ await redisClient?.set(`verify_${token}`, JSON.stringify(pendingUser), { ex: 24*60*60 });
+
+ const verifyUrl = `${authUrl}/api/verify-email?token=${token}`;
+
+ await sendEmail({
+ to: email,
+ subject: "Verify your IIITL Alumni Account",
+ text: `Click here to verify your account: ${verifyUrl}`,
+ html: `Verify Account`
+ });
+
+ return { success: true, message: "Check your email to verify your account." };
+ } catch (error) {
+ if (error instanceof Error) {
+ return { success: false, message: error.message };
+ }
+ return { success: false, message: "Registration failed." };
+ }
+}
+
+export async function signUpWithGoogle() {
+ try {
+ await signIn("google", { redirectTo: "/" })
+ } catch (error) {
+ if (error instanceof AuthError) {
+ redirect("/login?error=AccessDenied")
+ }
+ throw error
+ }
+}
\ No newline at end of file
diff --git a/app/register/page.tsx b/app/register/page.tsx
index ee52f8e..c3c1d31 100644
--- a/app/register/page.tsx
+++ b/app/register/page.tsx
@@ -1,5 +1,6 @@
import Link from "next/link";
import { Section } from "@/components/Section";
+import RegisterForm from "@/app/register/RegistrationForm";
export const metadata = { title: "Join the network" };
@@ -14,45 +15,9 @@ export default function RegisterPage() {
Create your profile to access the directory, attend events, post
jobs, and stay in the loop.
-
+
+
+
Already a member?{" "}
diff --git a/app/setup-password/actions.ts b/app/setup-password/actions.ts
new file mode 100644
index 0000000..4d453a4
--- /dev/null
+++ b/app/setup-password/actions.ts
@@ -0,0 +1,75 @@
+"use server";
+
+import bcrypt from "bcryptjs";
+import clientPromise from "@/lib/mongodb";
+import { redisClient } from "@/lib/ratelimit";
+import { redirect } from "next/navigation";
+
+export async function completeGoogleSignup(token: string, formData: FormData) {
+ if (!token || typeof token !== "string" || !token.trim()) {
+ redirect("/setup-password?error=InvalidToken")
+ }
+
+ const password = formData.get("password") as string;
+ if (!password || password.length < 8) {
+ throw new Error("Password must be at least 8 characters long.");
+ }
+
+ if (!redisClient) {
+ throw new Error("Internal Server Error: Redis required for atomic signups.");
+ }
+
+ // 1. Fetch pending Google data from Redis
+ const rawData = await redisClient.get(`pending_google_${token.trim()}`);
+ if (!rawData) {
+ redirect("/setup-password?error=TokenExpired")
+ }
+
+ // Upstash returns deeply parsed JSON natively sometimes depending on setup, but handle string fallback
+ let pendingData: unknown
+ try {
+ pendingData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+ } catch {
+ redirect("/setup-password?error=InvalidTokenPayload")
+ }
+
+ if (
+ !pendingData ||
+ typeof pendingData !== "object" ||
+ !("email" in pendingData) ||
+ !("googleAccountId" in pendingData) ||
+ typeof (pendingData as { email: unknown }).email !== "string" ||
+ typeof (pendingData as { googleAccountId: unknown }).googleAccountId !== "string"
+ ) {
+ redirect("/setup-password?error=InvalidTokenPayload")
+ }
+
+ // 2. Hash the new password safely
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ // 3. ATOMIC SAVE: Insert User and Account manually into MongoDB
+ const db = (await clientPromise).db();
+
+ // Create user
+ const result = await db.collection("users").insertOne({
+ name: (pendingData as { name?: string }).name,
+ email: (pendingData as { email: string }).email,
+ image: (pendingData as { image?: string }).image,
+ hashedPassword: hashedPassword,
+ emailVerified: new Date(), // They proved email ownership via Google
+ });
+
+ // Link the Google Account to this new User so NextAuth recognizes it later
+ await db.collection("accounts").insertOne({
+ userId: result.insertedId,
+ type: "oauth",
+ provider: "google",
+ providerAccountId: (pendingData as { googleAccountId: string }).googleAccountId,
+ });
+
+ // 4. Delete the Redis token to prevent duplicate account creation
+ await redisClient.del(`pending_google_${token.trim()}`);
+
+ // 5. Registration fully atomic! Send them to login (or directly sign them in if configured)
+ redirect("/login?success=AccountCreated");
+}
\ No newline at end of file
diff --git a/app/setup-password/page.tsx b/app/setup-password/page.tsx
new file mode 100644
index 0000000..e96bacd
--- /dev/null
+++ b/app/setup-password/page.tsx
@@ -0,0 +1,54 @@
+import { completeGoogleSignup } from "@/app/setup-password/actions";
+import { Section } from "@/components/Section";
+
+export const metadata = { title: "Complete Sign Up" };
+
+export default async function SetupPasswordPage(
+ props: { searchParams: Promise<{ token?: string }> }
+) {
+ const searchParams = await props.searchParams;
+ const token = searchParams.token;
+
+ if (!token) {
+ return (
+
+
Invalid or missing session token.
+
+ );
+ }
+
+ // Bind the token to the server action so it can't be tampered with by the user
+ const actionWithToken = completeGoogleSignup.bind(null, token);
+
+ return (
+
+
+
Almost there!
+
+ Your Google account was verified successfully. Please set a password to
+ complete your registration.
+