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() {
@@ -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
-
-
-
);
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 (
+
+
+
+
+
+
+
+ {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(),
});