Sign in to access the alumni directory, events, and the job board.
-
New to IIITL Alumni?{" "}
diff --git a/app/register/actions.ts b/app/register/actions.ts
new file mode 100644
index 0000000..04d0c39
--- /dev/null
+++ b/app/register/actions.ts
@@ -0,0 +1,16 @@
+"use server"
+
+import { signIn } from "@/auth"
+import { AuthError } from "next-auth"
+import { redirect } from "next/navigation"
+
+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..38b7ab4 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 { signUpWithGoogle } from "@/app/register/actions";
export const metadata = { title: "Join the network" };
@@ -52,6 +53,20 @@ export default function RegisterPage() {
Create account
+
+
+
or
+
+
+
+
+
Already a member?{" "}
diff --git a/app/setup-password/actions.ts b/app/setup-password/actions.ts
new file mode 100644
index 0000000..dba7577
--- /dev/null
+++ b/app/setup-password/actions.ts
@@ -0,0 +1,55 @@
+"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) {
+ 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}`);
+ if (!rawData) {
+ throw new Error("Session expired. Please try Google sign-in again.");
+ }
+
+ // Upstash returns deeply parsed JSON natively sometimes depending on setup, but handle string fallback
+ const pendingData = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
+
+ // 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.name,
+ email: pendingData.email,
+ image: pendingData.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.googleAccountId,
+ });
+
+ // 4. Delete the Redis token to prevent duplicate account creation
+ await redisClient.del(`pending_google_${token}`);
+
+ // 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.
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/auth.ts b/auth.ts
index f16ed55..af71310 100644
--- a/auth.ts
+++ b/auth.ts
@@ -3,7 +3,7 @@ import Google from "next-auth/providers/google"
import { sendEmail } from "./lib/email"
import clientPromise from "./lib/mongodb"
import { MongoDBAdapter } from "@auth/mongodb-adapter";
-import { limit } from "./lib/ratelimit";
+import { limit, redisClient } from "./lib/ratelimit";
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: MongoDBAdapter(clientPromise),
@@ -41,6 +41,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}
try {
+ console.log(`magic link: ${url}`)
await sendEmail({
to: identifier,
subject: "Sign in to the IIITL Platform",
@@ -66,17 +67,38 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
},
],
callbacks: {
- async signIn({ user, profile }) {
+ async signIn({ user, account, profile }) {
const email = user.email || profile?.email;
const isIiitlEmail = email && /@iiitl\.ac\.in$/i.test(email);
if (!isIiitlEmail) {
- // Returning false rejects the sign-in
console.warn(`${email} does not have the right domain`)
return false;
}
+ if (account?.provider === "google") {
+ const db = (await clientPromise).db();
+ const existingUser = await db.collection("users").findOne({ email });
+
+ if (!existingUser) {
+ // 1. User doesn't exist yet! Let's NOT save them.
+ // 2. Temporarily store their Google profile in Redis (so we don't lose it)
+ const signupToken = crypto.randomUUID();
+ if(redisClient){
+ await redisClient.set(`pending_google_${signupToken}`, JSON.stringify({
+ name: profile?.name,
+ email: profile?.email,
+ image: profile?.picture,
+ googleAccountId: account.providerAccountId
+ }), { ex: 3600 }); // Expire in 1 hour
+ }
+
+ // 3. Abort NextAuth's automatic save and redirect to the password page
+ return `/setup-password?token=${signupToken}`;
+ }
+ }
+
return true;
},
},
diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts
index cf4fd43..aa15fc6 100644
--- a/lib/ratelimit.ts
+++ b/lib/ratelimit.ts
@@ -1,7 +1,7 @@
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
-const redisClient = (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN)
+export const redisClient = (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN)
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
diff --git a/package.json b/package.json
index 7c14e9a..2369afe 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@auth/mongodb-adapter": "^3.11.1",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
+ "bcryptjs": "^3.0.3",
"dotenv": "^17.4.1",
"form-data": "^4.0.5",
"mailgun.js": "^12.7.1",
@@ -25,6 +26,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e42f106..8c13f8f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@upstash/redis':
specifier: ^1.37.0
version: 1.37.0
+ bcryptjs:
+ specifier: ^3.0.3
+ version: 3.0.3
dotenv:
specifier: ^17.4.1
version: 17.4.1
@@ -48,6 +51,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.2.2
+ '@types/bcryptjs':
+ specifier: ^3.0.0
+ version: 3.0.0
'@types/node':
specifier: ^20
version: 20.19.39
@@ -751,6 +757,10 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/bcryptjs@3.0.0':
+ resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
+ deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1047,6 +1057,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bcryptjs@3.0.3:
+ resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
+ hasBin: true
+
brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
@@ -2899,6 +2913,10 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/bcryptjs@3.0.0':
+ dependencies:
+ bcryptjs: 3.0.3
+
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -3204,6 +3222,8 @@ snapshots:
baseline-browser-mapping@2.10.17: {}
+ bcryptjs@3.0.3: {}
+
brace-expansion@1.1.13:
dependencies:
balanced-match: 1.0.2
diff --git a/public/google.svg b/public/google.svg
new file mode 100644
index 0000000..c0669b3
--- /dev/null
+++ b/public/google.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
From 5efc74a986561091ac1fa7e818c35cdeb8c17478 Mon Sep 17 00:00:00 2001
From: Ewan-Dkhar
Date: Fri, 17 Apr 2026 14:52:30 +0530
Subject: [PATCH 5/7] Add sign up with form
---
.env.example | 1 +
app/api/verify-email/route.ts | 38 ++++++++++++++++++
app/register/RegistrationForm.tsx | 65 +++++++++++++++++++++++++++++++
app/register/actions.ts | 46 ++++++++++++++++++++++
app/register/page.tsx | 58 ++-------------------------
auth.ts | 34 +++++++++++++---
6 files changed, 183 insertions(+), 59 deletions(-)
create mode 100644 app/api/verify-email/route.ts
create mode 100644 app/register/RegistrationForm.tsx
diff --git a/.env.example b/.env.example
index f20640c..02f5ab1 100644
--- a/.env.example
+++ b/.env.example
@@ -16,6 +16,7 @@ EMAIL_FROM="no-reply@yourdomain.com"
# Auth
AUTH_SECRET=""
+AUTH_URL=""
# Google
GOOGLE_CLIENT_ID="your-client-id-here.apps.googleusercontent.com"
diff --git a/app/api/verify-email/route.ts b/app/api/verify-email/route.ts
new file mode 100644
index 0000000..e0b0330
--- /dev/null
+++ b/app/api/verify-email/route.ts
@@ -0,0 +1,38 @@
+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();
+
+ // Ensure we don't accidentally create duplicates if they click twice
+ const existing = await db.collection("users").findOne({ email: pendingUser.email });
+ if (!existing) {
+ await db.collection("users").insertOne({
+ ...pendingUser,
+ emailVerified: new Date(),
+ createdAt: new Date(),
+ });
+ }
+
+ // 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/register/RegistrationForm.tsx b/app/register/RegistrationForm.tsx
new file mode 100644
index 0000000..af35475
--- /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
index 04d0c39..02de419 100644
--- a/app/register/actions.ts
+++ b/app/register/actions.ts
@@ -3,6 +3,52 @@
import { signIn } from "@/auth"
import { AuthError } from "next-auth"
import { redirect } from "next/navigation"
+import bcrypt from "bcryptjs";
+import crypto from "crypto";
+import { redisClient } from "@/lib/ratelimit";
+import { sendEmail } from "@/lib/email";
+
+export async function handleRegistration(prevState: unknown, formData: FormData) {
+ try {
+ const email = formData.get("email") as string;
+ const password = formData.get("password") as string;
+ const branch = formData.get("branch") as string;
+ const graduationYear = formData.get("graduationYear") as string;
+ const name = formData.get("name") as string;
+
+ if (!/@iiitl\.ac\.in$/i.test(email)) throw new Error("Invalid domain");
+
+ // 1. Hash the password
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ // 2. Create a secure verification token
+ const token = crypto.randomUUID();
+
+ // 3. Store pending user data in Redis (Expires in 24 hours)
+ const pendingUser = {
+ email,
+ name,
+ branch,
+ graduationYear,
+ hashedPassword,
+ };
+
+ await redisClient?.set(`verify_${token}`, JSON.stringify(pendingUser), { ex: 24*60*60 });
+
+ const verifyUrl = `${process.env.AUTH_URL}/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: any) {
+ return { success: false, message: error.message || "Registration failed." };
+ }
+}
export async function signUpWithGoogle() {
try {
diff --git a/app/register/page.tsx b/app/register/page.tsx
index 38b7ab4..c3c1d31 100644
--- a/app/register/page.tsx
+++ b/app/register/page.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { Section } from "@/components/Section";
-import { signUpWithGoogle } from "@/app/register/actions";
+import RegisterForm from "@/app/register/RegistrationForm";
export const metadata = { title: "Join the network" };
@@ -15,59 +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/auth.ts b/auth.ts
index af71310..110beaf 100644
--- a/auth.ts
+++ b/auth.ts
@@ -1,9 +1,11 @@
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
-import { sendEmail } from "./lib/email"
-import clientPromise from "./lib/mongodb"
+import Credentials from "next-auth/providers/credentials"
+import { sendEmail } from "@/lib/email"
+import clientPromise from "@/lib/mongodb"
import { MongoDBAdapter } from "@auth/mongodb-adapter";
-import { limit, redisClient } from "./lib/ratelimit";
+import { limit, redisClient } from "@/lib/ratelimit";
+import bcrypt from "bcryptjs"
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: MongoDBAdapter(clientPromise),
@@ -41,7 +43,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}
try {
- console.log(`magic link: ${url}`)
await sendEmail({
to: identifier,
subject: "Sign in to the IIITL Platform",
@@ -65,6 +66,29 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
}
},
},
+ Credentials({
+ name: "Credentials",
+ credentials: {
+ email: { label: "Email", type: "email" },
+ password: { label: "Password", type: "password" }
+ },
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) return null;
+
+ const db = (await clientPromise).db();
+ const user = await db.collection("users").findOne({ email: credentials.email });
+
+ // If user doesn't exist or has no password
+ if (!user || !user.hashedPassword) return null;
+
+ const isValid = await bcrypt.compare(credentials.password as string, user.hashedPassword);
+
+ if (!isValid) return null;
+
+ // Return the user object if password matches
+ return { id: user._id.toString(), email: user.email, name: user.name };
+ }
+ })
],
callbacks: {
async signIn({ user, account, profile }) {
@@ -91,7 +115,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
email: profile?.email,
image: profile?.picture,
googleAccountId: account.providerAccountId
- }), { ex: 3600 }); // Expire in 1 hour
+ }), { ex: 3600 });
}
// 3. Abort NextAuth's automatic save and redirect to the password page
From a3a432bd644cb696f7bd6c690a8c24a6c9e32082 Mon Sep 17 00:00:00 2001
From: Ewan-Dkhar
Date: Fri, 17 Apr 2026 15:10:23 +0530
Subject: [PATCH 6/7] Add sign in with form
---
app/login/actions.ts | 19 +++++++++++++++++++
app/login/page.tsx | 21 ++++++++++++++++-----
app/register/actions.ts | 7 +++++--
lib/db.ts | 2 --
4 files changed, 40 insertions(+), 9 deletions(-)
diff --git a/app/login/actions.ts b/app/login/actions.ts
index 0b2bd26..2e912f1 100644
--- a/app/login/actions.ts
+++ b/app/login/actions.ts
@@ -21,6 +21,25 @@ export async function signInWithMagicLink(formData: FormData) {
}
}
+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: "/" })
diff --git a/app/login/page.tsx b/app/login/page.tsx
index f0a3f43..dd6ec35 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,6 +1,6 @@
import Link from "next/link";
import { Section } from "@/components/Section";
-import { signInWithMagicLink, signInWithGoogle } from "./actions";
+import { signInWithCredentials, signInWithMagicLink, signInWithGoogle } from "./actions";
export const metadata = { title: "Sign in" };
@@ -12,22 +12,25 @@ export default function LoginPage() {
Sign in to access the alumni directory, events, and the job board.