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.

-
+
+ + + + +
+
+
or
+
+
+

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 ( +

+ + {/* Display the message from the server action here! */} + {state?.message && ( +
+ {state.message} +
+ )} + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+
or
+
+
+
+ +
+
+ ); +} \ 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. +

+ +
+
+ + +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..381e234 --- /dev/null +++ b/auth.ts @@ -0,0 +1,163 @@ +import NextAuth from "next-auth" +import Google from "next-auth/providers/google" +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 bcrypt from "bcryptjs" +import { randomUUID } from "crypto" + +export const { handlers, signIn, signOut, auth } = NextAuth({ + adapter: MongoDBAdapter(clientPromise), + session: { + maxAge: 30 * 24 * 60 * 60, + updateAge: 24 * 60 * 60, + }, + providers: [ + Google({ + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + checks: ["pkce"], + authorization: { + params: { + prompt: "consent", + access_type: "offline", + response_type: "code", + hd: "iiitl.ac.in", + }, + }, + }), + { + id: "email", + name: "Email", + type: "email", + async sendVerificationRequest({ identifier, url }) { + const { success } = await limit(`magic_link_${identifier}`, { + maxRequests: 5, + window: '1h' + }); + + if(!success) { + console.warn(`Rate limit exceeded for magic link: ${identifier}`); + throw new Error("Rate limit exceeded") + } + + try { + await sendEmail({ + to: identifier, + subject: "Sign in to the IIITL Platform", + html: ` +
+

Welcome to the IIITL Platform

+

Click the link below to securely sign in.

+ + Sign In + +

+ If you didn't request this, you can safely ignore this email. +

+
+ `, + text: `Sign in to the IIITL Platform by clicking this link: ${url}` + }); + } catch (error) { + console.error("Failed to send magic link via custom mailer:", error); + throw new Error("Failed to send verification email."); + } + }, + }, + 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 }) { + // Check if the email is actually from @iiitl.ac.in + if (account?.provider === "google") { + const hd = profile?.hd || profile?.domain; + if (hd !== "iiitl.ac.in") { + console.warn(`Denied Google sign-in: Incorrect Hosted Domain '${hd}'`); + return false; + } + } + + const email = user.email || profile?.email; + + const isIiitlEmail = email && /@iiitl\.ac\.in$/i.test(email); + + if (!isIiitlEmail) { + 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) { + if (!redisClient) { + console.error("Google signup blocked: redisClient unavailable for pending signup token storage") + return "/login?error=SignupUnavailable" + } + + const signupToken = randomUUID(); + try { + const setResult = await redisClient.set( + `pending_google_${signupToken}`, + JSON.stringify({ + name: profile?.name, + email: profile?.email, + image: profile?.picture, + googleAccountId: account.providerAccountId, + }), + { ex: 3600 } + ) + + if (!setResult) { + console.error("Google signup blocked: failed to persist pending signup token") + return "/login?error=SignupUnavailable" + } + } catch (error) { + console.error("Google signup blocked: redis set failed", error) + return "/login?error=SignupUnavailable" + } + + return `/setup-password?token=${signupToken}`; + } + + const linkedGoogleAccount = await db.collection("accounts").findOne({ + userId: existingUser._id, + provider: "google", + }) + + if (!linkedGoogleAccount) { + console.warn(`Google sign-in rejected: credentials-only account for ${email}`) + return "/login?error=OAuthAccountNotLinked" + } + } + + return true; + }, + }, +}) \ No newline at end of file diff --git a/lib/db.ts b/lib/db.ts index 2d725a0..09ede13 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,7 +1,5 @@ import mongoose from "mongoose"; -const MONGODB_URI = process.env.MONGODB_URI; - declare global { var mongoose: { conn: mongoose.Mongoose | null; diff --git a/lib/mongodb.ts b/lib/mongodb.ts new file mode 100644 index 0000000..b73fef6 --- /dev/null +++ b/lib/mongodb.ts @@ -0,0 +1,7 @@ +import { connectDB } from "./db"; + +const clientPromise = connectDB().then((mongooseInstance) => { + return mongooseInstance.connection.getClient(); +}); + +export default clientPromise; \ No newline at end of file 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 b79f11e..82e9fd1 100644 --- a/package.json +++ b/package.json @@ -10,25 +10,35 @@ "db:seed": "node scripts/seed.js" }, "dependencies": { + "@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", - "mongoose": "^9.4.1", + "mongoose": "^8.9.5", "next": "16.2.3", + "next-auth": "5.0.0-beta.30", "react": "19.2.4", "react-dom": "19.2.4" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.3", + "npm-check-updates": "^22.0.1", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5" + }, + "pnpm": { + "overrides": { + "mongodb": "6.21.0" + } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20000f7..1cf8d64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,16 +4,25 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + mongodb: 6.21.0 + importers: .: dependencies: + '@auth/mongodb-adapter': + specifier: ^3.11.1 + version: 3.11.1(mongodb@6.21.0) '@upstash/ratelimit': specifier: ^2.0.8 version: 2.0.8(@upstash/redis@1.37.0) '@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 @@ -24,11 +33,14 @@ importers: specifier: ^12.7.1 version: 12.7.1 mongoose: - specifier: ^9.4.1 - version: 9.4.1 + specifier: ^8.9.5 + version: 8.23.1 next: specifier: 16.2.3 version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.30 + version: 5.0.0-beta.30(next@16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -39,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 @@ -54,6 +69,9 @@ importers: eslint-config-next: specifier: 16.2.3 version: 16.2.3(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + npm-check-updates: + specifier: ^22.0.1 + version: 22.0.1 tailwindcss: specifier: ^4 version: 4.2.2 @@ -70,6 +88,39 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@auth/core@0.41.0': + resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@auth/core@0.41.1': + resolution: {integrity: sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@auth/mongodb-adapter@3.11.1': + resolution: {integrity: sha512-xY+VUkC3CNXct8UwQgBAQqXASqolSlIARg6oAm1378CtRN2650tQUCOEnGLNLmroVefUeP73M6t+TpGXq72vwQ==} + peerDependencies: + mongodb: 6.21.0 + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -605,6 +656,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -706,6 +760,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==} @@ -729,8 +787,8 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} - '@types/whatwg-url@13.0.0': - resolution: {integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==} + '@types/whatwg-url@11.0.5': + resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} @@ -1002,6 +1060,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==} @@ -1018,9 +1080,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bson@7.2.0: - resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} - engines: {node: '>=20.19.0'} + bson@6.10.4: + resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} + engines: {node: '>=16.20.1'} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -1606,6 +1668,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1640,9 +1705,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} - kareem@3.2.0: - resolution: {integrity: sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==} - engines: {node: '>=18.0.0'} + kareem@2.6.3: + resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} + engines: {node: '>=12.0.0'} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1786,21 +1851,20 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mongodb-connection-string-url@7.0.1: - resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} - engines: {node: '>=20.19.0'} + mongodb-connection-string-url@3.0.2: + resolution: {integrity: sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==} - mongodb@7.1.1: - resolution: {integrity: sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w==} - engines: {node: '>=20.19.0'} + mongodb@6.21.0: + resolution: {integrity: sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==} + engines: {node: '>=16.20.1'} peerDependencies: - '@aws-sdk/credential-providers': ^3.806.0 - '@mongodb-js/zstd': ^7.0.0 - gcp-metadata: ^7.0.1 - kerberos: ^7.0.0 - mongodb-client-encryption: '>=7.0.0 <7.1.0' + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' snappy: ^7.3.2 - socks: ^2.8.6 + socks: ^2.7.1 peerDependenciesMeta: '@aws-sdk/credential-providers': optional: true @@ -1817,17 +1881,17 @@ packages: socks: optional: true - mongoose@9.4.1: - resolution: {integrity: sha512-4rFBWa+/wdBQSfvnOPJBpiSG6UCEbhSQh865dEdaH9Y8WfHBUC+I2XT28dp0IBIGrEwmh+gzrgZgea5PbmrHWA==} - engines: {node: '>=20.19.0'} + mongoose@8.23.1: + resolution: {integrity: sha512-gHSPD8qEwRmiXapK17hEnFWZdcFENMegHTcw5XIIg2+7R8eXQvdwSiMpD/A2oG8tKzFLLHyRXd8/eaDPAVwZgQ==} + engines: {node: '>=16.20.1'} mpath@0.9.0: resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} engines: {node: '>=4.0.0'} - mquery@6.0.0: - resolution: {integrity: sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==} - engines: {node: '>=20.19.0'} + mquery@5.0.0: + resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} + engines: {node: '>=14.0.0'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1845,6 +1909,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.30: + resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next@16.2.3: resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} @@ -1873,6 +1953,14 @@ packages: node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + npm-check-updates@22.0.1: + resolution: {integrity: sha512-K8PDu7l9v7UKIwDSxLnqA9LHT76Mu4eCjGjp0JwSeSsyKWmX/YZY+AoBxw4oVdKwQLthWbzg1g+OKysHYGQCjQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: '>=10.0.0'} + hasBin: true + + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1959,6 +2047,14 @@ packages: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2295,6 +2391,31 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@auth/core@0.41.0': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.2 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + + '@auth/core@0.41.1': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.2 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + + '@auth/mongodb-adapter@3.11.1(mongodb@6.21.0)': + dependencies: + '@auth/core': 0.41.1 + mongodb: 6.21.0 + transitivePeerDependencies: + - '@simplewebauthn/browser' + - '@simplewebauthn/server' + - nodemailer + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2717,6 +2838,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.15': @@ -2797,6 +2920,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': {} @@ -2817,7 +2944,7 @@ snapshots: '@types/webidl-conversions@7.0.3': {} - '@types/whatwg-url@13.0.0': + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -3102,6 +3229,8 @@ snapshots: baseline-browser-mapping@2.10.17: {} + bcryptjs@3.0.3: {} + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 @@ -3123,7 +3252,7 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bson@7.2.0: {} + bson@6.10.4: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -3879,6 +4008,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -3906,7 +4037,7 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - kareem@3.2.0: {} + kareem@2.6.3: {} keyv@4.5.4: dependencies: @@ -4025,23 +4156,24 @@ snapshots: minimist@1.2.8: {} - mongodb-connection-string-url@7.0.1: + mongodb-connection-string-url@3.0.2: dependencies: - '@types/whatwg-url': 13.0.0 + '@types/whatwg-url': 11.0.5 whatwg-url: 14.2.0 - mongodb@7.1.1: + mongodb@6.21.0: dependencies: '@mongodb-js/saslprep': 1.4.6 - bson: 7.2.0 - mongodb-connection-string-url: 7.0.1 + bson: 6.10.4 + mongodb-connection-string-url: 3.0.2 - mongoose@9.4.1: + mongoose@8.23.1: dependencies: - kareem: 3.2.0 - mongodb: 7.1.1 + bson: 6.10.4 + kareem: 2.6.3 + mongodb: 6.21.0 mpath: 0.9.0 - mquery: 6.0.0 + mquery: 5.0.0 ms: 2.1.3 sift: 17.1.3 transitivePeerDependencies: @@ -4052,10 +4184,15 @@ snapshots: - mongodb-client-encryption - snappy - socks + - supports-color mpath@0.9.0: {} - mquery@6.0.0: {} + mquery@5.0.0: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color ms@2.1.3: {} @@ -4065,6 +4202,12 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.30(next@16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + dependencies: + '@auth/core': 0.41.0 + next: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + next@16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.3 @@ -4098,6 +4241,10 @@ snapshots: node-releases@2.0.37: {} + npm-check-updates@22.0.1: {} + + oauth4webapi@3.8.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4193,6 +4340,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} prop-types@15.8.1: 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