From 0f7722e51312d42284d9f59aafe7963fe79b0a64 Mon Sep 17 00:00:00 2001 From: isnowglobal-admin Date: Mon, 22 Jun 2026 21:40:10 -0400 Subject: [PATCH] release: alpha release candidate --- client/src/App.tsx | 13 +- client/src/components/app-sidebar.tsx | 9 -- client/src/lib/auth-context.tsx | 45 ++++--- client/src/pages/login.tsx | 186 +++++++++++++++++--------- client/src/pages/verify.tsx | 117 ++++++++++++++++ package-lock.json | 10 ++ package.json | 1 + server/authRoutes.ts | 89 +++++++++++- server/db.ts | 35 +++++ server/email.ts | 54 ++++++++ server/middleware.ts | 28 +++- shared/schema.ts | 5 +- 12 files changed, 492 insertions(+), 100 deletions(-) create mode 100644 client/src/pages/verify.tsx create mode 100644 server/email.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index a64c9e8..25f87f6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,7 @@ import ScreenerPage from "@/pages/screener"; import SettingsPage from "@/pages/settings"; import AccountPage from "@/pages/account"; import LoginPage from "@/pages/login"; +import VerifyPage from "@/pages/verify"; import NotFound from "@/pages/not-found"; import { Loader2 } from "lucide-react"; @@ -29,6 +30,7 @@ function usePageTitle(): string { if (location.startsWith("/settings")) return "Settings & API"; if (location.startsWith("/account")) return "Account"; if (location.startsWith("/login")) return "Sign In"; + if (location.startsWith("/verify")) return "Verify Email"; return "Dashboard"; } @@ -37,7 +39,6 @@ function ProtectedRoute({ component: Component }: { component: () => JSX.Element const { user, loading } = useAuth(); const [, setLocation] = useLocation(); - // Must call hooks unconditionally before any early return useEffect(() => { if (!loading && !user) { setLocation("/login"); @@ -62,6 +63,16 @@ function ProtectedRoute({ component: Component }: { component: () => JSX.Element function AppShell() { const title = usePageTitle(); const [location] = useLocation(); + + // Handle pathname-based verify URLs (from email links) - bypass hash router + // This must be checked BEFORE the hash router takes over + const pathname = window.location.pathname; + const isVerifyPath = pathname.startsWith("/verify"); + + if (isVerifyPath) { + return ; + } + const isAuthPage = location === "/login"; return (
diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index 95c5b93..b496111 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -78,15 +78,6 @@ export function AppSidebar() { - -

- Analytics & education only. Not financial advice. Data is simulated - ORATS-style mock data until an API key is provided. -

-
); } diff --git a/client/src/lib/auth-context.tsx b/client/src/lib/auth-context.tsx index 0c3092a..bd9fb39 100644 --- a/client/src/lib/auth-context.tsx +++ b/client/src/lib/auth-context.tsx @@ -5,17 +5,19 @@ interface User { id: number; name: string; email: string; + emailVerified?: boolean; } interface AuthContextType { user: User | null; loading: boolean; login: (email: string, password: string) => Promise; - register: (name: string, email: string, password: string) => Promise; + register: (name: string, email: string, password: string) => Promise<{ ok: boolean; email?: string }>; logout: () => Promise; updateProfile: (data: { name?: string; email?: string }) => Promise; changePassword: (current: string, newPass: string) => Promise; requestReset: (email: string) => Promise; + resendVerification: (email: string) => Promise; error: string | null; clearError: () => void; } @@ -41,17 +43,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { const clearError = () => setError(null); - const tryAuth = async (fn: () => Promise): Promise<{ ok: boolean }> => { - try { - await fn(); - return { ok: true }; - } catch (err: any) { - const msg = err?.message || "Something went wrong"; - setError(msg); - return { ok: false }; - } - }; - const login = async (email: string, password: string) => { clearError(); const res = await fetch("/api/auth/login", { @@ -66,7 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const data: User = await res.json(); setUser(data); - setLocation("/"); + // Use full page navigation to dashboard - bypasses hash router issues + window.location.hash = "/"; }; const register = async (name: string, email: string, password: string) => { @@ -79,11 +71,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!res.ok) { const err = await res.json().catch(() => ({ error: "Registration failed" })); setError(err.error || "Registration failed"); - return; + return { ok: false }; } - const data: User = await res.json(); - setUser(data); - setLocation("/"); + const data = await res.json(); + // Registration succeeded — email sent, do NOT auto-login + return { ok: true, email: data.email }; }; const logout = async () => { @@ -141,8 +133,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { return true; }; + const resendVerification = async (email: string) => { + clearError(); + const res = await fetch("/api/auth/resend-verification", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed to resend" })); + setError(err.error || "Failed to resend verification email"); + return false; + } + const data = await res.json(); + setError(null); + return data; + }; + return ( - + {children} ); diff --git a/client/src/pages/login.tsx b/client/src/pages/login.tsx index f716dfc..8c4fa62 100644 --- a/client/src/pages/login.tsx +++ b/client/src/pages/login.tsx @@ -6,13 +6,16 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { AlertCircle, Eye, EyeOff, Loader2, ArrowRight } from "lucide-react"; +import { AlertCircle, Eye, EyeOff, Loader2, ArrowRight, Mail, CheckCircle2 } from "lucide-react"; export default function LoginPage() { const { login, register, error, clearError } = useAuth(); const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); + // After successful registration + const [regSuccess, setRegSuccess] = useState(null); + // Login form const [loginEmail, setLoginEmail] = useState(""); const [loginPassword, setLoginPassword] = useState(""); @@ -26,6 +29,7 @@ export default function LoginPage() { e.preventDefault(); setLoading(true); clearError(); + setRegSuccess(null); await login(loginEmail, loginPassword); setLoading(false); }, [loginEmail, loginPassword, login, clearError]); @@ -34,8 +38,16 @@ export default function LoginPage() { e.preventDefault(); setLoading(true); clearError(); - await register(regName, regEmail, regPassword); + setRegSuccess(null); + const result = await register(regName, regEmail, regPassword); setLoading(false); + if (result?.ok) { + setRegSuccess(result.email || regEmail); + // Reset form + setRegName(""); + setRegEmail(""); + setRegPassword(""); + } }, [regName, regEmail, regPassword, register, clearError]); return ( @@ -49,7 +61,7 @@ export default function LoginPage() {
- + G
@@ -64,7 +76,27 @@ export default function LoginPage() { )} - clearError()}> + {/* Registration Success Message */} + {regSuccess && ( + + + +

Registration successful!

+

We've sent a verification email to {regSuccess}

+

Please check your inbox and click the link to verify your account before logging in.

+ +
+
+ )} + + { clearError(); setRegSuccess(null); }}> Sign In Create Account @@ -128,72 +160,96 @@ export default function LoginPage() { - - - Create your account - Sign up to access the dashboard - - -
-
- - setRegName(e.target.value)} - placeholder="John Doe" - required - autoComplete="name" - className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" - /> -
-
- - setRegEmail(e.target.value)} - placeholder="you@example.com" - required - autoComplete="email" - className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" - /> -
-
- - setRegPassword(e.target.value)} - placeholder="Min 6 characters" - required - minLength={6} - autoComplete="new-password" - className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" - /> + {!regSuccess ? ( + + + Create your account + Sign up to access the dashboard + + + +
+ + setRegName(e.target.value)} + placeholder="John Doe" + required + autoComplete="name" + className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" + /> +
+
+ + setRegEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" + /> +
+
+ + setRegPassword(e.target.value)} + placeholder="Min 8 characters" + required + minLength={8} + autoComplete="new-password" + className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" + /> +
+ + +
+
+ ) : ( + + +
+
+ Check your email + + We sent a verification link to {regSuccess} + +
+ + + + + Click the link in the email to activate your account, then come back and sign in. + + - - -
+ + + )} - - - {/* Footer */} -

- Analytics & education only. Not financial advice. -

+
); diff --git a/client/src/pages/verify.tsx b/client/src/pages/verify.tsx new file mode 100644 index 0000000..36b87bb --- /dev/null +++ b/client/src/pages/verify.tsx @@ -0,0 +1,117 @@ +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CheckCircle2, XCircle, Loader2, Mail } from "lucide-react"; + +export default function VerifyPage() { + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + const [message, setMessage] = useState(""); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + + if (!token) { + setStatus("error"); + setMessage("No verification token provided."); + return; + } + + fetch(`/api/auth/verify/${token}`) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Verification failed"); + } + return data; + }) + .then(() => { + setStatus("success"); + setMessage("Email verified successfully!"); + // Auto-login set by server via session cookie. + // Redirect to dashboard using full page navigation (not wouter hash routing) + // so the auth context picks up the new session cookie. + setTimeout(() => { + window.location.href = "/#/"; + }, 2000); + }) + .catch((err) => { + setStatus("error"); + setMessage(err.message || "Verification failed."); + }); + }, []); + + return ( +
+
+
+
+
+ + + + G + +
+

GammaDesk

+
+ + + + {status === "loading" && ( + <> +
+ +
+ Verifying... + + Please wait while we verify your email + + + )} + {status === "success" && ( + <> +
+ +
+ Account Verified! + + {message} + + + )} + {status === "error" && ( + <> +
+ +
+ Verification Failed + + {message} + + + )} +
+ + {status === "success" && ( +
+ Redirecting to dashboard... +
+ )} + {status === "error" && ( + + )} +
+
+
+
+ ); +} diff --git a/package-lock.json b/package-lock.json index ef8f935..34d1425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "lucide-react": "^0.453.0", "memorystore": "^1.6.7", "next-themes": "^0.4.6", + "nodemailer": "^8.0.11", "passport": "^0.7.0", "passport-local": "^1.0.0", "pg": "^8.21.0", @@ -6619,6 +6620,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index c9b17ea..54ddd70 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "lucide-react": "^0.453.0", "memorystore": "^1.6.7", "next-themes": "^0.4.6", + "nodemailer": "^8.0.11", "passport": "^0.7.0", "passport-local": "^1.0.0", "pg": "^8.21.0", diff --git a/server/authRoutes.ts b/server/authRoutes.ts index 301bb23..2fb8903 100644 --- a/server/authRoutes.ts +++ b/server/authRoutes.ts @@ -1,8 +1,10 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires import * as bcrypt from "bcryptjs"; +import * as crypto from "crypto"; import type { Express, Request, Response } from "express"; -import { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword } from "./db"; +import { createUser, getUserByEmail, getUserById, updateUser, updateUserPassword, updateUserVerification, getUserByVerificationToken, markEmailVerified } from "./db"; import { sanitizeInput } from "./middleware"; +import { sendVerificationEmail } from "./email"; export function requireAuth(req: Request, res: Response, next: () => void) { const session = req.session as Record; @@ -52,10 +54,19 @@ export function registerAuthRoutes(app: Express) { passwordHash: hash, }); - const session = req.session as Record; - session.userId = user.id; + // Generate verification token + const token = crypto.randomUUID(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours + await updateUserVerification(user.id, token, expiry); - res.status(201).json({ id: user.id, name: user.name, email: user.email }); + // Send verification email + await sendVerificationEmail(sanitizedEmail, sanitizedName, token); + + res.status(201).json({ + ok: true, + message: "Registration successful. Please check your email to verify your account.", + email: sanitizedEmail, + }); } catch (error) { console.error("Registration error:", error); res.status(500).json({ error: "Internal server error" }); @@ -83,6 +94,14 @@ export function registerAuthRoutes(app: Express) { return res.status(401).json({ error: "Invalid email or password" }); } + // Check if email is verified + if (!user.emailVerified) { + return res.status(403).json({ + error: "Please verify your email before logging in. Check your inbox.", + needsVerification: true, + }); + } + const session = req.session as Record; session.userId = user.id; @@ -93,6 +112,66 @@ export function registerAuthRoutes(app: Express) { } }); + // GET /api/auth/verify/:token + app.get("/api/auth/verify/:token", async (req: Request, res: Response) => { + const { token } = req.params; + + try { + const user = await getUserByVerificationToken(token); + if (!user) { + return res.status(400).json({ + error: "Invalid or expired verification link. Please register again.", + }); + } + + await markEmailVerified(user.id); + + // Auto-login after verification + const session = req.session as Record; + session.userId = user.id; + + res.json({ + ok: true, + message: "Email verified successfully!", + user: { id: user.id, name: user.name, email: user.email }, + }); + } catch (error) { + console.error("Verification error:", error); + res.status(500).json({ error: "Internal server error" }); + } + }); + + // POST /api/auth/resend-verification (optional: resend verification email) + app.post("/api/auth/resend-verification", async (req: Request, res: Response) => { + const { email } = req.body as Record; + + if (!email) { + return res.status(400).json({ error: "Email is required" }); + } + + try { + const user = await getUserByEmail(email.toLowerCase()); + if (!user) { + // Don't reveal whether account exists + return res.json({ ok: true, message: "If an account exists, a verification email has been sent." }); + } + + if (user.emailVerified) { + return res.json({ ok: true, message: "Email is already verified." }); + } + + const token = crypto.randomUUID(); + const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); + await updateUserVerification(user.id, token, expiry); + await sendVerificationEmail(user.email, user.name, token); + + res.json({ ok: true, message: "Verification email sent. Please check your inbox." }); + } catch (error) { + console.error("Resend verification error:", error); + res.status(500).json({ error: "Internal server error" }); + } + }); + // POST /api/auth/logout app.post("/api/auth/logout", (req: Request, res: Response) => { req.session.destroy(() => { @@ -109,7 +188,7 @@ export function registerAuthRoutes(app: Express) { req.session.destroy(); return res.status(401).json({ error: "Not authenticated" }); } - res.json({ id: user.id, name: user.name, email: user.email }); + res.json({ id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified }); } catch (error) { console.error("Get user error:", error); res.status(500).json({ error: "Internal server error" }); diff --git a/server/db.ts b/server/db.ts index e7a900b..8042f37 100644 --- a/server/db.ts +++ b/server/db.ts @@ -26,9 +26,25 @@ export async function initDb(): Promise { name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + verification_token TEXT, + verification_token_expiry TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ) `); + + // Add columns if they don't exist (for existing tables) + await addColumnIfExists('email_verified', 'BOOLEAN NOT NULL DEFAULT FALSE'); + await addColumnIfExists('verification_token', 'TEXT'); + await addColumnIfExists('verification_token_expiry', 'TIMESTAMP WITH TIME ZONE'); +} + +async function addColumnIfExists(column: string, typeDef: string): Promise { + try { + await db.execute(`ALTER TABLE users ADD COLUMN ${column} ${typeDef}`); + } catch { + // Column already exists or table doesn't exist — ignore + } } export async function createUser(data: NewUser): Promise { @@ -55,3 +71,22 @@ export async function updateUserPassword(id: number, passwordHash: string): Prom const result = await db.update(users).set({ passwordHash }).where(eq(users.id, id)).returning(); return result[0]; } + +export async function updateUserVerification(id: number, token: string, expiry: Date): Promise { + await db.update(users).set({ verificationToken: token, verificationTokenExpiry: expiry }).where(eq(users.id, id)); +} + +export async function getUserByVerificationToken(token: string): Promise { + const { and, eq, gt } = await import("drizzle-orm"); + const result = await db.select().from(users).where( + and( + eq(users.verificationToken, token), + gt(users.verificationTokenExpiry, new Date()) + ) + ); + return result[0]; +} + +export async function markEmailVerified(id: number): Promise { + await db.update(users).set({ emailVerified: true, verificationToken: null, verificationTokenExpiry: null }).where(eq(users.id, id)); +} diff --git a/server/email.ts b/server/email.ts new file mode 100644 index 0000000..6a0813e --- /dev/null +++ b/server/email.ts @@ -0,0 +1,54 @@ +import nodemailer from "nodemailer"; + +// Configure transport based on environment +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || "localhost", + port: parseInt(process.env.SMTP_PORT || "1025"), + secure: process.env.SMTP_SECURE === "true", + auth: process.env.SMTP_USER && process.env.SMTP_PASS + ? { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + } + : undefined, // MailHog doesn't need auth in dev +}); + +export async function sendVerificationEmail( + to: string, + name: string, + token: string, +): Promise { + const baseUrl = process.env.APP_URL || `http://localhost:${process.env.PORT || 3000}`; + const verifyUrl = `${baseUrl}/verify?token=${token}`; + + const mailOptions: Record = { + from: process.env.SMTP_FROM || `"GammaDesk" `, + to, + subject: "Verify your GammaDesk account", + text: `GammaDesk - Verify Your Account\n\nHello ${name},\n\nThanks for signing up! Please verify your email by visiting:\n\n${verifyUrl}\n\nThis link expires in 24 hours. If you didn't create this account, you can safely ignore this email.`, + html: ` +
+

Welcome to GammaDesk, ${name}!

+

+ Thanks for signing up. Please verify your email address by clicking the button below. +

+ + Verify Email Address + +

+ This link will expire in 24 hours.
+ If you didn't create this account, you can safely ignore this email. +

+
+ `, + }; + + try { + await transporter.sendMail(mailOptions); + console.log(`[Email] Verification email sent to ${to}`); + return true; + } catch (error) { + console.error("[Email] Failed to send verification email:", error); + return false; + } +} diff --git a/server/middleware.ts b/server/middleware.ts index 34c68b2..2beb851 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -78,9 +78,35 @@ export function securityHeaders(_req: Request, res: Response, next: NextFunction res.set("X-XSS-Protection", "1; mode=block"); res.set("Referrer-Policy", "strict-origin-when-cross-origin"); res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + // Development CSP: Relaxed for Vite HMR + Google Fonts + const devCsp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' http: https: data:", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https://fonts.gstatic.com", + "connect-src 'self' ws: wss: http: https:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "); + + // Production CSP: Strict + const prodCsp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "img-src 'self' data: blob:", + "font-src 'self' data: https://fonts.gstatic.com", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "); + res.set( "Content-Security-Policy", - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + process.env.NODE_ENV === "production" ? prodCsp : devCsp, ); if (process.env.NODE_ENV === "production") { diff --git a/shared/schema.ts b/shared/schema.ts index 5d129aa..1904041 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -3,7 +3,7 @@ // Database: PostgreSQL import { z } from "zod"; -import { pgTable, text, serial, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, timestamp, boolean } from "drizzle-orm/pg-core"; // --------------------------------------------------------------------------- // Auth - Users table (Drizzle + PostgreSQL) @@ -14,6 +14,9 @@ export const users = pgTable("users", { name: text("name").notNull(), email: text("email").notNull().unique(), passwordHash: text("password_hash").notNull(), + emailVerified: boolean("email_verified").default(false).notNull(), + verificationToken: text("verification_token"), + verificationTokenExpiry: timestamp("verification_token_expiry", { mode: "date" }), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), });