From b2450e700ce847d853a07dd13a803141afe6e83b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 15 Feb 2026 20:11:04 -0500 Subject: [PATCH 1/6] god damnn it --- packages/auth/src/config.ts | 2 +- .../(portal)/api/auth/verify-email/route.ts | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index de35c68..51bf841 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -118,7 +118,7 @@ export const authConfig: NextAuthConfig = { expires, }); - console.log(`[sendVerificationRequest] Token stored for ${identifier}`); + // console.log(`[sendVerificationRequest] Token stored for ${identifier}`); // Build /verify URL with our custom token const verifyUrl = new URL("/verify", parsedUrl.origin); diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts index 5d74ba0..60f2517 100644 --- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { db, verificationTokens, users, sessions } from "@query/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; /** * Custom email verification endpoint. @@ -23,6 +23,7 @@ export async function GET(request: NextRequest) { try { if (!db) { + console.error("[verify-email] Database connection not available"); return NextResponse.redirect(`${baseUrl}/auth/error?error=Configuration`); } @@ -30,24 +31,25 @@ export async function GET(request: NextRequest) { // the token value as-is (no hashing) with a "custom:" prefix const customTokenValue = `custom:${tokenParam}`; - console.log(`[verify-email] Looking up token for ${email}`); + console.log(`[verify-email] Starting verification for ${email}`); - const result = await db - .delete(verificationTokens) - .where( - and( - eq(verificationTokens.identifier, email), - eq(verificationTokens.token, customTokenValue) - ) - ) - .returning(); + // Use raw SQL to avoid potential Drizzle schema/type mismatches (e.g. "boolin" error) + // Table name is "verificationToken" (singular) per schema definition + const result = await db.execute(sql` + DELETE FROM "verificationToken" + WHERE "identifier" = ${email} AND "token" = ${customTokenValue} + RETURNING * + `); - if (result.length === 0) { + if (result.rowCount === 0) { console.warn(`[verify-email] No matching token found for ${email} — link may be expired or already used`); return NextResponse.redirect(`${baseUrl}/auth/error?error=Verification`); } - const invite = result[0]; + // Force cast to expected type + const invite = result.rows[0] as typeof verificationTokens.$inferSelect; + + console.log(`[verify-email] Token found and consumed.`); // Check expiry if (new Date(invite.expires) < new Date()) { @@ -104,7 +106,7 @@ export async function GET(request: NextRequest) { }); return response; - } catch (error) { + } catch (error: any) { console.error("[verify-email] Error:", error); return NextResponse.redirect(`${baseUrl}/auth/error?error=Verification`); } From 36bb89b31452999a7eaf723a70b0f0a419c2f41b Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 15 Feb 2026 21:03:53 -0500 Subject: [PATCH 2/6] LOL --- packages/auth/src/adapter.ts | 43 +++++++++++++++++++++++++++--------- packages/auth/src/config.ts | 35 +++++------------------------ 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/packages/auth/src/adapter.ts b/packages/auth/src/adapter.ts index e1f899d..e12aa33 100644 --- a/packages/auth/src/adapter.ts +++ b/packages/auth/src/adapter.ts @@ -1,6 +1,7 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter"; import { db, users, accounts, sessions, verificationTokens } from "@query/db"; -import type { Adapter } from "next-auth/adapters"; +import { sql } from "drizzle-orm"; +import type { Adapter, VerificationToken } from "next-auth/adapters"; // Only create adapter if database is available and properly initialized function createAdapter(): Adapter | undefined { @@ -18,20 +19,40 @@ function createAdapter(): Adapter | undefined { verificationTokensTable: verificationTokens, }); - // Override token methods — our custom sendVerificationRequest (config.ts) - // stores its own token with a "custom:" prefix, and our custom - // /api/auth/verify-email route consumes it directly. - // NextAuth's default flow creates a *hashed* token that our custom - // verify route can never match, causing Verification_Failed errors. + // Override token methods with raw SQL to avoid Drizzle "boolin" type errors. + // The base DrizzleAdapter's generated queries hit a Postgres type mismatch + // in our deployment environment. return { ...baseAdapter, - createVerificationToken: async (token) => { - // No-op: our sendVerificationRequest handles token creation + createVerificationToken: async ( + token: VerificationToken + ): Promise => { + if (!db) throw new Error("Database not available"); + await db.execute(sql` + INSERT INTO "verificationToken" ("identifier", "token", "expires") + VALUES (${token.identifier}, ${token.token}, ${token.expires}) + `); return token; }, - useVerificationToken: async (params) => { - // No-op: our /api/auth/verify-email route handles token consumption - return null; + useVerificationToken: async (params: { + identifier: string; + token: string; + }): Promise => { + if (!db) return null; + const result = await db.execute(sql` + DELETE FROM "verificationToken" + WHERE "identifier" = ${params.identifier} AND "token" = ${params.token} + RETURNING * + `); + if (result.rowCount === 0) { + return null; + } + const row = result.rows[0] as any; + return { + identifier: row.identifier, + token: row.token, + expires: new Date(row.expires), + }; }, }; } catch (error) { diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 51bf841..0b03b0c 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -1,8 +1,6 @@ import type { NextAuthConfig } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import EmailProvider from "next-auth/providers/nodemailer"; -import { db, verificationTokens } from "@query/db"; -import { randomBytes } from "crypto"; function html(params: { url: string; host: string }) { const { url, host } = params; @@ -93,6 +91,8 @@ export const authConfig: NextAuthConfig = { pool: true, }, from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", + // Custom email template — we send our branded email but let NextAuth + // handle token generation, hashing, and the callback URL. sendVerificationRequest: async ({ identifier, url, provider }) => { // @ts-ignore const { createTransport } = await import("nodemailer"); @@ -100,39 +100,16 @@ export const authConfig: NextAuthConfig = { const parsedUrl = new URL(url); const host = parsedUrl.host; - const callbackUrl = parsedUrl.searchParams.get("callbackUrl") || "/dashboard"; - // Generate our own random token and store it directly in the DB. - // This bypasses NextAuth's internal token hashing which causes - // Verification_Failed errors in our deployment environment. - const customToken = randomBytes(32).toString("hex"); - const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - - if (!db) { - throw new Error("Database not available — cannot store verification token"); - } - - await db.insert(verificationTokens).values({ - identifier, - token: `custom:${customToken}`, - expires, - }); - - // console.log(`[sendVerificationRequest] Token stored for ${identifier}`); - - // Build /verify URL with our custom token - const verifyUrl = new URL("/verify", parsedUrl.origin); - verifyUrl.searchParams.set("token", customToken); - verifyUrl.searchParams.set("email", identifier); - verifyUrl.searchParams.set("callbackUrl", callbackUrl); - const safeUrl = verifyUrl.toString(); + // Use NextAuth's callback URL directly — it includes the hashed token. + // No need to generate or store our own token; the adapter handles it. const result = await transport.sendMail({ to: identifier, from: provider.from, subject: `Sign in to ${host}`, - text: `Sign in to ${host}\n${safeUrl}\n\n`, - html: html({ url: safeUrl, host }), + text: `Sign in to ${host}\n${url}\n\n`, + html: html({ url, host }), }); const failed = result.rejected.concat(result.pending).filter(Boolean); From e22d67beec1503725e07ee4590a07f0cec27aeaf Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 15 Feb 2026 21:16:15 -0500 Subject: [PATCH 3/6] fixing --- packages/auth/src/adapter.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/auth/src/adapter.ts b/packages/auth/src/adapter.ts index e12aa33..9c3bdb7 100644 --- a/packages/auth/src/adapter.ts +++ b/packages/auth/src/adapter.ts @@ -3,9 +3,7 @@ import { db, users, accounts, sessions, verificationTokens } from "@query/db"; import { sql } from "drizzle-orm"; import type { Adapter, VerificationToken } from "next-auth/adapters"; -// Only create adapter if database is available and properly initialized function createAdapter(): Adapter | undefined { - // Check both that db exists and that DATABASE_URL was set if (!db || !process.env.DATABASE_URL) { console.warn("Auth adapter: No database connection, using JWT sessions"); return undefined; @@ -19,21 +17,11 @@ function createAdapter(): Adapter | undefined { verificationTokensTable: verificationTokens, }); - // Override token methods with raw SQL to avoid Drizzle "boolin" type errors. - // The base DrizzleAdapter's generated queries hit a Postgres type mismatch - // in our deployment environment. return { ...baseAdapter, - createVerificationToken: async ( - token: VerificationToken - ): Promise => { - if (!db) throw new Error("Database not available"); - await db.execute(sql` - INSERT INTO "verificationToken" ("identifier", "token", "expires") - VALUES (${token.identifier}, ${token.token}, ${token.expires}) - `); - return token; - }, + // createVerificationToken: use DrizzleAdapter's default — it works fine. + // Only useVerificationToken needs raw SQL to avoid the Drizzle "boolin" + // type error on DELETE queries in our deployment environment. useVerificationToken: async (params: { identifier: string; token: string; From d07fa147ffd0e5bb4dfea02f097dae59eda37cb9 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sun, 15 Feb 2026 21:27:16 -0500 Subject: [PATCH 4/6] idek --- packages/auth/src/adapter.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/auth/src/adapter.ts b/packages/auth/src/adapter.ts index 9c3bdb7..c7627cf 100644 --- a/packages/auth/src/adapter.ts +++ b/packages/auth/src/adapter.ts @@ -17,11 +17,23 @@ function createAdapter(): Adapter | undefined { verificationTokensTable: verificationTokens, }); + // Override BOTH token methods with raw SQL to avoid Drizzle "boolin" + // type errors that affect all verificationToken queries in our + // deployment environment when using the pgTable compound primary key. return { ...baseAdapter, - // createVerificationToken: use DrizzleAdapter's default — it works fine. - // Only useVerificationToken needs raw SQL to avoid the Drizzle "boolin" - // type error on DELETE queries in our deployment environment. + createVerificationToken: async ( + token: VerificationToken + ): Promise => { + if (!db) throw new Error("Database not available"); + // Convert expires to ISO string for reliable Postgres timestamp handling + const expiresISO = token.expires.toISOString(); + await db.execute(sql` + INSERT INTO "verificationToken" ("identifier", "token", "expires") + VALUES (${token.identifier}, ${token.token}, ${expiresISO}::timestamp) + `); + return token; + }, useVerificationToken: async (params: { identifier: string; token: string; From 91ab58cb09a71775601bbe3151191d968a80f06c Mon Sep 17 00:00:00 2001 From: aamoghS Date: Mon, 16 Feb 2026 12:29:56 -0500 Subject: [PATCH 5/6] ok lets do it --- packages/auth/src/config.ts | 41 +++++++++++++++---- .../(portal)/api/auth/[...nextauth]/route.ts | 8 +++- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 0b03b0c..77c752d 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -91,25 +91,48 @@ export const authConfig: NextAuthConfig = { pool: true, }, from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", - // Custom email template — we send our branded email but let NextAuth - // handle token generation, hashing, and the callback URL. + // Custom email template with our own token — links to /verify page + // to prevent email-client HEAD prefetch from consuming the token. sendVerificationRequest: async ({ identifier, url, provider }) => { + // Generate our own token and store it with a "custom:" prefix so our + // verify-email route can look it up directly (no NextAuth hashing). + const rawToken = crypto.randomUUID(); + const customToken = `custom:${rawToken}`; + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + + // Dynamic import to avoid circular dependency + const { adapter } = await import("./adapter"); + if (adapter?.createVerificationToken) { + await adapter.createVerificationToken({ + identifier, + token: customToken, + expires, + }); + } + + // Build URL to our /verify page — the user clicks a button there, + // which prevents email-client HEAD prefetch from consuming the token. + const baseUrl = + process.env.NEXTAUTH_URL || + process.env.AUTH_URL || + "https://datasciencegt.org"; + const verifyUrl = new URL("/verify", baseUrl); + verifyUrl.searchParams.set("token", rawToken); + verifyUrl.searchParams.set("email", identifier); + verifyUrl.searchParams.set("callbackUrl", "/dashboard"); + // @ts-ignore const { createTransport } = await import("nodemailer"); const transport = createTransport(provider.server); - const parsedUrl = new URL(url); - const host = parsedUrl.host; - - // Use NextAuth's callback URL directly — it includes the hashed token. - // No need to generate or store our own token; the adapter handles it. + const host = verifyUrl.host; const result = await transport.sendMail({ to: identifier, from: provider.from, subject: `Sign in to ${host}`, - text: `Sign in to ${host}\n${url}\n\n`, - html: html({ url, host }), + text: `Sign in to ${host}\n${verifyUrl.toString()}\n\n`, + html: html({ url: verifyUrl.toString(), host }), }); const failed = result.rejected.concat(result.pending).filter(Boolean); diff --git a/sites/mainweb/app/(portal)/api/auth/[...nextauth]/route.ts b/sites/mainweb/app/(portal)/api/auth/[...nextauth]/route.ts index 62a6945..2464303 100644 --- a/sites/mainweb/app/(portal)/api/auth/[...nextauth]/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/[...nextauth]/route.ts @@ -3,4 +3,10 @@ import { handlers } from "@query/auth"; const { GET: _GET, POST: _POST } = handlers; export const GET = _GET as any; -export const POST = _POST as any; \ No newline at end of file +export const POST = _POST as any; + +// Return 200 for HEAD requests (email client link preview/prefetch) +// to prevent UnknownAction errors from cluttering logs +export function HEAD() { + return new Response(null, { status: 200 }); +} \ No newline at end of file From 6712b2359512f0054198f8298f92e738f55bfd59 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Mon, 16 Feb 2026 12:50:04 -0500 Subject: [PATCH 6/6] uhhhhhhhhhhhhhhhhh --- packages/auth/src/config.ts | 110 ++++++------ .../(portal)/api/auth/verify-email/route.ts | 91 +++++----- sites/mainweb/app/(portal)/login/page.tsx | 10 +- sites/mainweb/app/(portal)/verify/page.tsx | 159 +++++++++++++++--- 4 files changed, 248 insertions(+), 122 deletions(-) diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 77c752d..0a69bc8 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -1,14 +1,15 @@ import type { NextAuthConfig } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import EmailProvider from "next-auth/providers/nodemailer"; +import { db } from "@query/db"; +import { sql } from "drizzle-orm"; -function html(params: { url: string; host: string }) { - const { url, host } = params; +function html(params: { code: string; host: string }) { + const { code, host } = params; - // Liquid Glass Design with Teal/Emerald Gradient - const mainColor = "#10b981"; // Emerald-500 - const backgroundColor = "#0f172a"; // Slate-900 - const textColor = "#f8fafc"; // Slate-50 + const mainColor = "#10b981"; + const backgroundColor = "#0f172a"; + const textColor = "#f8fafc"; return ` @@ -18,33 +19,35 @@ function html(params: { url: string; host: string }) { - + - + -

DataScienceGT

-

Secure Sign In

+

Your Verification Code

-

- Click the button below to authenticate your access to ${host}. This link expires in 24 hours. +

+ Enter this code on ${host} to sign in. It expires in 10 minutes.

- - - - - -
- - Sign In Now - -
- - + +
+ + + ${code + .split("") + .map( + (d) => + `` + ) + .join("")} + +
${d}
+
+

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

@@ -68,9 +71,7 @@ export const authConfig: NextAuthConfig = { GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - // Allows Google login to "claim" the pre-seeded user record via email match allowDangerousEmailAccountLinking: true, - // Disable all checks for Firebase proxy (cookies don't transfer) checks: [], authorization: { params: { @@ -91,48 +92,41 @@ export const authConfig: NextAuthConfig = { pool: true, }, from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", - // Custom email template with our own token — links to /verify page - // to prevent email-client HEAD prefetch from consuming the token. + // 6-digit code flow — no magic link, user types the code. sendVerificationRequest: async ({ identifier, url, provider }) => { - // Generate our own token and store it with a "custom:" prefix so our - // verify-email route can look it up directly (no NextAuth hashing). - const rawToken = crypto.randomUUID(); - const customToken = `custom:${rawToken}`; - const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - - // Dynamic import to avoid circular dependency - const { adapter } = await import("./adapter"); - if (adapter?.createVerificationToken) { - await adapter.createVerificationToken({ - identifier, - token: customToken, - expires, - }); + // Generate a 6-digit numeric code + const code = Math.floor(100000 + Math.random() * 900000).toString(); + const customToken = `custom:${code}`; + const expires = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + // Store code in DB + if (db) { + const expiresISO = expires.toISOString(); + await db.execute(sql` + INSERT INTO "verificationToken" ("identifier", "token", "expires") + VALUES (${identifier}, ${customToken}, ${expiresISO}::timestamp) + `); + console.log(`[sendVerificationRequest] Stored code for ${identifier}`); + } else { + console.error(`[sendVerificationRequest] No DB — code NOT stored for ${identifier}`); } - // Build URL to our /verify page — the user clicks a button there, - // which prevents email-client HEAD prefetch from consuming the token. - const baseUrl = - process.env.NEXTAUTH_URL || - process.env.AUTH_URL || - "https://datasciencegt.org"; - const verifyUrl = new URL("/verify", baseUrl); - verifyUrl.searchParams.set("token", rawToken); - verifyUrl.searchParams.set("email", identifier); - verifyUrl.searchParams.set("callbackUrl", "/dashboard"); - // @ts-ignore const { createTransport } = await import("nodemailer"); const transport = createTransport(provider.server); - const host = verifyUrl.host; + const baseUrl = + process.env.NEXTAUTH_URL || + process.env.AUTH_URL || + "https://datasciencegt.org"; + const host = new URL(baseUrl).host; const result = await transport.sendMail({ to: identifier, from: provider.from, - subject: `Sign in to ${host}`, - text: `Sign in to ${host}\n${verifyUrl.toString()}\n\n`, - html: html({ url: verifyUrl.toString(), host }), + subject: `${code} — Your sign-in code for ${host}`, + text: `Your sign-in code is: ${code}\n\nEnter this code on ${host} to sign in. It expires in 10 minutes.\n\nIf you didn't request this, you can safely ignore this email.\n`, + html: html({ code, host }), }); const failed = result.rejected.concat(result.pending).filter(Boolean); @@ -150,13 +144,11 @@ export const authConfig: NextAuthConfig = { callbacks: { async session({ session, user }) { if (user && session.user) { - // Ensures the ID generated during seeding is the ID used in the session session.user.id = user.id; } return session; }, async redirect({ url, baseUrl }) { - // Handle callback URLs if (url.startsWith("/")) { return `${baseUrl}${url}`; } else if (new URL(url).origin === baseUrl) { diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts index 60f2517..fbea95a 100644 --- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -1,40 +1,38 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, verificationTokens, users, sessions } from "@query/db"; -import { eq, and, sql } from "drizzle-orm"; +import { db, users, sessions } from "@query/db"; +import { eq, sql } from "drizzle-orm"; /** - * Custom email verification endpoint. + * Code-based email verification endpoint. * - * Our sendVerificationRequest stores a separate token that we control. - * This endpoint looks up that token directly — no dependency on NextAuth's - * internal hashing mechanism. + * Accepts POST { code, email } — looks up `custom:` in the + * verificationToken table, consumes it, creates a session, and + * returns the session cookie + redirect URL. */ -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const tokenParam = searchParams.get("token"); - const email = searchParams.get("email"); - const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"; - - const baseUrl = process.env.NEXTAUTH_URL || process.env.AUTH_URL || "https://datasciencegt.org"; - - if (!tokenParam || !email) { - return NextResponse.redirect(`${baseUrl}/auth/error?error=Configuration`); - } - +export async function POST(request: NextRequest) { try { + const body = await request.json(); + const { code, email } = body as { code?: string; email?: string }; + + if (!code || !email) { + return NextResponse.json( + { success: false, error: "Missing code or email." }, + { status: 400 } + ); + } + if (!db) { console.error("[verify-email] Database connection not available"); - return NextResponse.redirect(`${baseUrl}/auth/error?error=Configuration`); + return NextResponse.json( + { success: false, error: "Server configuration error." }, + { status: 500 } + ); } - // Look up the token directly — our sendVerificationRequest stores - // the token value as-is (no hashing) with a "custom:" prefix - const customTokenValue = `custom:${tokenParam}`; + const customTokenValue = `custom:${code}`; + console.log(`[verify-email] Verifying code for ${email}`); - console.log(`[verify-email] Starting verification for ${email}`); - - // Use raw SQL to avoid potential Drizzle schema/type mismatches (e.g. "boolin" error) - // Table name is "verificationToken" (singular) per schema definition + // Consume the token (DELETE + RETURNING) const result = await db.execute(sql` DELETE FROM "verificationToken" WHERE "identifier" = ${email} AND "token" = ${customTokenValue} @@ -42,18 +40,23 @@ export async function GET(request: NextRequest) { `); if (result.rowCount === 0) { - console.warn(`[verify-email] No matching token found for ${email} — link may be expired or already used`); - return NextResponse.redirect(`${baseUrl}/auth/error?error=Verification`); + console.warn(`[verify-email] No matching code for ${email}`); + return NextResponse.json( + { success: false, error: "Invalid or expired code. Please try again." }, + { status: 401 } + ); } - // Force cast to expected type - const invite = result.rows[0] as typeof verificationTokens.$inferSelect; - - console.log(`[verify-email] Token found and consumed.`); + const invite = result.rows[0] as any; + console.log(`[verify-email] Code verified for ${email}`); // Check expiry if (new Date(invite.expires) < new Date()) { - return NextResponse.redirect(`${baseUrl}/auth/error?error=Verification`); + console.warn(`[verify-email] Code expired for ${email}`); + return NextResponse.json( + { success: false, error: "Code has expired. Please request a new one." }, + { status: 401 } + ); } // Find or create user @@ -70,6 +73,7 @@ export async function GET(request: NextRequest) { .values({ id: newId, email, emailVerified: new Date() }) .returning(); user = inserted[0]!; + console.log(`[verify-email] Created new user ${user.id}`); } else if (!user.emailVerified) { await db .update(users) @@ -87,16 +91,21 @@ export async function GET(request: NextRequest) { expires: sessionExpires, }); - // Build redirect response with session cookie - const redirectUrl = callbackUrl.startsWith("http") ? callbackUrl : `${baseUrl}${callbackUrl}`; - const response = NextResponse.redirect(redirectUrl); - - // Set the session cookie (same name NextAuth uses) + // Build JSON response with Set-Cookie header + const baseUrl = + process.env.NEXTAUTH_URL || + process.env.AUTH_URL || + "https://datasciencegt.org"; const isSecure = baseUrl.startsWith("https"); const cookieName = isSecure ? "__Secure-authjs.session-token" : "authjs.session-token"; + const response = NextResponse.json({ + success: true, + redirectUrl: "/dashboard", + }); + response.cookies.set(cookieName, sessionToken, { httpOnly: true, sameSite: "lax", @@ -105,9 +114,13 @@ export async function GET(request: NextRequest) { expires: sessionExpires, }); + console.log(`[verify-email] Session created for ${email}`); return response; } catch (error: any) { console.error("[verify-email] Error:", error); - return NextResponse.redirect(`${baseUrl}/auth/error?error=Verification`); + return NextResponse.json( + { success: false, error: "Server error. Please try again." }, + { status: 500 } + ); } } diff --git a/sites/mainweb/app/(portal)/login/page.tsx b/sites/mainweb/app/(portal)/login/page.tsx index 172afa5..6a6f3ac 100644 --- a/sites/mainweb/app/(portal)/login/page.tsx +++ b/sites/mainweb/app/(portal)/login/page.tsx @@ -87,14 +87,14 @@ export default function Home() { const handleEmailLogin = async () => { if (!email) return; setEmailSending(true); - setLogs(prev => [...prev.slice(-4), `> Sending verification link to ${email}...`]); + setLogs(prev => [...prev.slice(-4), `> Sending verification code to ${email}...`]); try { await signIn('nodemailer', { email, callbackUrl: '/dashboard', redirect: false }); - setEmailSent(true); - setLogs(prev => [...prev.slice(-4), "> Link sent! Check your inbox."]); + setLogs(prev => [...prev.slice(-4), "> Code sent! Redirecting..."]); + // Redirect to verify page where user enters the 6-digit code + router.push(`/verify?email=${encodeURIComponent(email)}`); } catch { - setLogs(prev => [...prev.slice(-4), "> Error: Failed to send link."]); - } finally { + setLogs(prev => [...prev.slice(-4), "> Error: Failed to send code."]); setEmailSending(false); } }; diff --git a/sites/mainweb/app/(portal)/verify/page.tsx b/sites/mainweb/app/(portal)/verify/page.tsx index f3db0db..64fafe1 100644 --- a/sites/mainweb/app/(portal)/verify/page.tsx +++ b/sites/mainweb/app/(portal)/verify/page.tsx @@ -1,23 +1,105 @@ 'use client'; -import React, { Suspense, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; +import React, { Suspense, useState, useRef, useEffect } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import Background from '@/components/portal/Background'; function VerifyContent() { const searchParams = useSearchParams(); + const router = useRouter(); + const [code, setCode] = useState(['', '', '', '', '', '']); const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(''); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - const token = searchParams?.get('token') || ''; const email = searchParams?.get('email') || ''; - const callbackUrl = searchParams?.get('callbackUrl') || '/dashboard'; - const handleVerify = () => { - if (!token) return; + // Auto-focus first input on mount + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + const handleChange = (index: number, value: string) => { + // Only allow digits + if (value && !/^\d$/.test(value)) return; + + const newCode = [...code]; + newCode[index] = value; + setCode(newCode); + setError(''); + + // Auto-advance to next input + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + + // Auto-submit when all 6 digits are entered + if (value && index === 5 && newCode.every(d => d !== '')) { + handleSubmit(newCode.join('')); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !code[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + if (e.key === 'Enter') { + const fullCode = code.join(''); + if (fullCode.length === 6) { + handleSubmit(fullCode); + } + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6); + if (pasted.length === 0) return; + + const newCode = [...code]; + for (let i = 0; i < 6; i++) { + newCode[i] = pasted[i] || ''; + } + setCode(newCode); + + // Focus the next empty input or the last one + const nextEmpty = newCode.findIndex(d => d === ''); + inputRefs.current[nextEmpty === -1 ? 5 : nextEmpty]?.focus(); + + // Auto-submit if all 6 digits pasted + if (pasted.length === 6) { + handleSubmit(pasted); + } + }; + + const handleSubmit = async (fullCode: string) => { + if (verifying) return; setVerifying(true); - // Redirect to our custom verification endpoint - const params = new URLSearchParams({ token, email, callbackUrl }); - window.location.href = `/api/auth/verify-email?${params.toString()}`; + setError(''); + + try { + const res = await fetch('/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: fullCode, email }), + }); + + const data = await res.json(); + + if (data.success) { + // Redirect — session cookie is set by the API + window.location.href = data.redirectUrl || '/dashboard'; + } else { + setError(data.error || 'Invalid code. Please try again.'); + setVerifying(false); + // Clear code and refocus + setCode(['', '', '', '', '', '']); + inputRefs.current[0]?.focus(); + } + } catch { + setError('Something went wrong. Please try again.'); + setVerifying(false); + } }; return ( @@ -26,20 +108,20 @@ function VerifyContent() {
- +
-
+

- Verify_Identity + Enter_Code

- Secure_Authentication // Email_Verification + Secure_Authentication // Code_Verification

- Click the button below to complete your sign-in. + We sent a 6-digit code to your email.

{email && (

@@ -48,19 +130,58 @@ function VerifyContent() { )}

+ {/* 6-digit code input */} +
+ {code.map((digit, i) => ( + { inputRefs.current[i] = el; }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleChange(i, e.target.value)} + onKeyDown={(e) => handleKeyDown(i, e)} + disabled={verifying} + className={` + w-12 h-16 sm:w-14 sm:h-18 text-center text-2xl font-mono font-bold + bg-black/60 border-2 rounded-lg + text-white focus:outline-none transition-all + ${error + ? 'border-red-500/50 focus:border-red-500' + : digit + ? 'border-emerald-500/50' + : 'border-white/10 focus:border-emerald-500/70' + } + ${verifying ? 'opacity-50' : ''} + shadow-[0_0_15px_rgba(16,185,129,0.05)] + `} + autoComplete="one-time-code" + /> + ))} +
+ + {/* Error message */} + {error && ( +

+ {error} +

+ )} + + {/* Submit button */}
- {!token && ( + {!email && (

- Error: Invalid or missing verification link. Please request a new sign-in link. + Error: Missing email. Please go back to the login page.

)}