diff --git a/packages/auth/src/adapter.ts b/packages/auth/src/adapter.ts index e1f899d..c7627cf 100644 --- a/packages/auth/src/adapter.ts +++ b/packages/auth/src/adapter.ts @@ -1,10 +1,9 @@ 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 { - // 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; @@ -18,20 +17,42 @@ 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 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: async (token) => { - // No-op: our sendVerificationRequest handles token creation + 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) => { - // 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 de35c68..0a69bc8 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -1,16 +1,15 @@ 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"; +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 ` @@ -20,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.

@@ -70,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: { @@ -93,46 +92,41 @@ export const authConfig: NextAuthConfig = { pool: true, }, from: process.env.EMAIL_FROM || "noreply@datasciencegt.org", + // 6-digit code flow — no magic link, user types the code. sendVerificationRequest: async ({ identifier, url, provider }) => { + // 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}`); + } + // @ts-ignore const { createTransport } = await import("nodemailer"); const transport = createTransport(provider.server); - 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(); + 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${safeUrl}\n\n`, - html: html({ url: safeUrl, 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/[...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 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..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,57 +1,62 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, verificationTokens, users, sessions } from "@query/db"; -import { eq, and } 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) { - return NextResponse.redirect(`${baseUrl}/auth/error?error=Configuration`); + console.error("[verify-email] Database connection not available"); + 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}`; - - console.log(`[verify-email] Looking up token for ${email}`); - - const result = await db - .delete(verificationTokens) - .where( - and( - eq(verificationTokens.identifier, email), - eq(verificationTokens.token, customTokenValue) - ) - ) - .returning(); - - if (result.length === 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 customTokenValue = `custom:${code}`; + console.log(`[verify-email] Verifying code for ${email}`); + + // Consume the token (DELETE + RETURNING) + const result = await db.execute(sql` + DELETE FROM "verificationToken" + WHERE "identifier" = ${email} AND "token" = ${customTokenValue} + RETURNING * + `); + + if (result.rowCount === 0) { + console.warn(`[verify-email] No matching code for ${email}`); + return NextResponse.json( + { success: false, error: "Invalid or expired code. Please try again." }, + { status: 401 } + ); } - const invite = result[0]; + 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 @@ -68,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) @@ -85,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", @@ -103,9 +114,13 @@ export async function GET(request: NextRequest) { expires: sessionExpires, }); + console.log(`[verify-email] Session created for ${email}`); return response; - } catch (error) { + } 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.

)}