release: alpha release candidate

master v0.1.0-alpha
isnowglobal-admin 2026-06-22 21:40:10 -04:00
parent 45340d8f85
commit 0f7722e513
12 changed files with 492 additions and 100 deletions

View File

@ -18,6 +18,7 @@ import ScreenerPage from "@/pages/screener";
import SettingsPage from "@/pages/settings"; import SettingsPage from "@/pages/settings";
import AccountPage from "@/pages/account"; import AccountPage from "@/pages/account";
import LoginPage from "@/pages/login"; import LoginPage from "@/pages/login";
import VerifyPage from "@/pages/verify";
import NotFound from "@/pages/not-found"; import NotFound from "@/pages/not-found";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@ -29,6 +30,7 @@ function usePageTitle(): string {
if (location.startsWith("/settings")) return "Settings & API"; if (location.startsWith("/settings")) return "Settings & API";
if (location.startsWith("/account")) return "Account"; if (location.startsWith("/account")) return "Account";
if (location.startsWith("/login")) return "Sign In"; if (location.startsWith("/login")) return "Sign In";
if (location.startsWith("/verify")) return "Verify Email";
return "Dashboard"; return "Dashboard";
} }
@ -37,7 +39,6 @@ function ProtectedRoute({ component: Component }: { component: () => JSX.Element
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
// Must call hooks unconditionally before any early return
useEffect(() => { useEffect(() => {
if (!loading && !user) { if (!loading && !user) {
setLocation("/login"); setLocation("/login");
@ -62,6 +63,16 @@ function ProtectedRoute({ component: Component }: { component: () => JSX.Element
function AppShell() { function AppShell() {
const title = usePageTitle(); const title = usePageTitle();
const [location] = useLocation(); 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 <VerifyPage />;
}
const isAuthPage = location === "/login"; const isAuthPage = location === "/login";
return ( return (
<div className="flex h-screen w-full overflow-hidden"> <div className="flex h-screen w-full overflow-hidden">

View File

@ -78,15 +78,6 @@ export function AppSidebar() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter className="px-4 pb-5 pt-3 border-t border-border/50">
<p
className="text-[10px] leading-relaxed text-muted-foreground"
data-testid="text-sidebar-disclaimer"
>
Analytics &amp; education only. Not financial advice. Data is simulated
ORATS-style mock data until an API key is provided.
</p>
</SidebarFooter>
</Sidebar> </Sidebar>
); );
} }

View File

@ -5,17 +5,19 @@ interface User {
id: number; id: number;
name: string; name: string;
email: string; email: string;
emailVerified?: boolean;
} }
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
loading: boolean; loading: boolean;
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>; register: (name: string, email: string, password: string) => Promise<{ ok: boolean; email?: string }>;
logout: () => Promise<void>; logout: () => Promise<void>;
updateProfile: (data: { name?: string; email?: string }) => Promise<void>; updateProfile: (data: { name?: string; email?: string }) => Promise<void>;
changePassword: (current: string, newPass: string) => Promise<void>; changePassword: (current: string, newPass: string) => Promise<void>;
requestReset: (email: string) => Promise<void>; requestReset: (email: string) => Promise<void>;
resendVerification: (email: string) => Promise<void>;
error: string | null; error: string | null;
clearError: () => void; clearError: () => void;
} }
@ -41,17 +43,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const clearError = () => setError(null); const clearError = () => setError(null);
const tryAuth = async <T,>(fn: () => Promise<T>): 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) => { const login = async (email: string, password: string) => {
clearError(); clearError();
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
@ -66,7 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
const data: User = await res.json(); const data: User = await res.json();
setUser(data); 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) => { const register = async (name: string, email: string, password: string) => {
@ -79,11 +71,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Registration failed" })); const err = await res.json().catch(() => ({ error: "Registration failed" }));
setError(err.error || "Registration failed"); setError(err.error || "Registration failed");
return; return { ok: false };
} }
const data: User = await res.json(); const data = await res.json();
setUser(data); // Registration succeeded — email sent, do NOT auto-login
setLocation("/"); return { ok: true, email: data.email };
}; };
const logout = async () => { const logout = async () => {
@ -141,8 +133,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return true; 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 ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, updateProfile, changePassword, requestReset, error, clearError }}> <AuthContext.Provider value={{ user, loading, login, register, logout, updateProfile, changePassword, requestReset, resendVerification, error, clearError }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@ -6,13 +6,16 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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() { export default function LoginPage() {
const { login, register, error, clearError } = useAuth(); const { login, register, error, clearError } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
// After successful registration
const [regSuccess, setRegSuccess] = useState<string | null>(null);
// Login form // Login form
const [loginEmail, setLoginEmail] = useState(""); const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState(""); const [loginPassword, setLoginPassword] = useState("");
@ -26,6 +29,7 @@ export default function LoginPage() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
clearError(); clearError();
setRegSuccess(null);
await login(loginEmail, loginPassword); await login(loginEmail, loginPassword);
setLoading(false); setLoading(false);
}, [loginEmail, loginPassword, login, clearError]); }, [loginEmail, loginPassword, login, clearError]);
@ -34,8 +38,16 @@ export default function LoginPage() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
clearError(); clearError();
await register(regName, regEmail, regPassword); setRegSuccess(null);
const result = await register(regName, regEmail, regPassword);
setLoading(false); setLoading(false);
if (result?.ok) {
setRegSuccess(result.email || regEmail);
// Reset form
setRegName("");
setRegEmail("");
setRegPassword("");
}
}, [regName, regEmail, regPassword, register, clearError]); }, [regName, regEmail, regPassword, register, clearError]);
return ( return (
@ -49,7 +61,7 @@ export default function LoginPage() {
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 border border-primary/20 mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 border border-primary/20 mb-4">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none"> <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M16 4L4 16L16 28L28 16L16 4Z" fill="hsl(130 70% 50%)" fillOpacity="0.2"/> <path d="M16 4L4 16L16 28L28 16L16 4Z" fill="hsl(130 70% 50%)" fillOpacity="0.2"/>
<path d="M16 8L8 16L16 24L24 16L16 8Z" fill="hsl(130 70% 50%)"/> <path d="M16 8L8 16L16 24L24 16L16 8Z" fill="hsl(130 70% 50%)" />
<text x="16" y="19" textAnchor="middle" fill="white" fontSize="10" fontWeight="bold">G</text> <text x="16" y="19" textAnchor="middle" fill="white" fontSize="10" fontWeight="bold">G</text>
</svg> </svg>
</div> </div>
@ -64,7 +76,27 @@ export default function LoginPage() {
</Alert> </Alert>
)} )}
<Tabs defaultValue="login" className="w-full" onValueChange={() => clearError()}> {/* Registration Success Message */}
{regSuccess && (
<Alert className="mb-4 border-primary/50 bg-primary/10">
<Mail className="h-4 w-4" />
<AlertDescription>
<p className="font-semibold">Registration successful!</p>
<p className="text-sm mt-1">We've sent a verification email to <strong>{regSuccess}</strong></p>
<p className="text-sm mt-1">Please check your inbox and click the link to verify your account before logging in.</p>
<Button
variant="ghost"
size="sm"
className="mt-2 text-primary h-auto p-0 text-xs"
onClick={() => setRegSuccess(null)}
>
Back to sign in
</Button>
</AlertDescription>
</Alert>
)}
<Tabs defaultValue="login" className="w-full" onValueChange={() => { clearError(); setRegSuccess(null); }}>
<TabsList className="grid w-full grid-cols-2 bg-card border border-border mb-6"> <TabsList className="grid w-full grid-cols-2 bg-card border border-border mb-6">
<TabsTrigger value="login" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-lg">Sign In</TabsTrigger> <TabsTrigger value="login" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-lg">Sign In</TabsTrigger>
<TabsTrigger value="register" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-lg">Create Account</TabsTrigger> <TabsTrigger value="register" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-lg">Create Account</TabsTrigger>
@ -128,6 +160,7 @@ export default function LoginPage() {
</TabsContent> </TabsContent>
<TabsContent value="register"> <TabsContent value="register">
{!regSuccess ? (
<Card className="border-border bg-card/50 backdrop-blur-sm shadow-xl"> <Card className="border-border bg-card/50 backdrop-blur-sm shadow-xl">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-xl font-semibold">Create your account</CardTitle> <CardTitle className="text-xl font-semibold">Create your account</CardTitle>
@ -168,9 +201,9 @@ export default function LoginPage() {
type="password" type="password"
value={regPassword} value={regPassword}
onChange={(e) => setRegPassword(e.target.value)} onChange={(e) => setRegPassword(e.target.value)}
placeholder="Min 6 characters" placeholder="Min 8 characters"
required required
minLength={6} minLength={8}
autoComplete="new-password" autoComplete="new-password"
className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20" className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20"
/> />
@ -187,13 +220,36 @@ export default function LoginPage() {
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
) : (
<Card className="border-border bg-card/50 backdrop-blur-sm shadow-xl border-primary/30">
<CardHeader className="pb-4 text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-2">
<CheckCircle2 className="h-7 w-7 text-primary" />
</div>
<CardTitle className="text-xl font-semibold">Check your email</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
We sent a verification link to <strong>{regSuccess}</strong>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Alert className="border-primary/30 bg-primary/5">
<Mail className="h-4 w-4" />
<AlertDescription className="text-sm">
Click the link in the email to activate your account, then come back and sign in.
</AlertDescription>
</Alert>
<Button
variant="outline"
onClick={() => setRegSuccess(null)}
className="w-full h-12"
>
Back to Sign In
</Button>
</CardContent>
</Card>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{/* Footer */}
<p className="text-center text-xs text-muted-foreground mt-8">
Analytics & education only. Not financial advice.
</p>
</div> </div>
</div> </div>
); );

117
client/src/pages/verify.tsx Normal file
View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="absolute inset-0 bg-gradient-to-b from-primary/5 via-transparent to-transparent pointer-events-none" />
<div className="relative w-full max-w-md">
<div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 border border-primary/20 mb-4">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M16 4L4 16L16 28L28 16L16 4Z" fill="hsl(130 70% 50%)" fillOpacity="0.2" />
<path d="M16 8L8 16L16 24L24 16L16 8Z" fill="hsl(130 70% 50%)" />
<text x="16" y="19" textAnchor="middle" fill="white" fontSize="10" fontWeight="bold">G</text>
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">GammaDesk</h1>
</div>
<Card className="border-border bg-card/50 backdrop-blur-sm shadow-xl">
<CardHeader className="pb-4 text-center">
{status === "loading" && (
<>
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-2">
<Loader2 className="h-7 w-7 text-primary animate-spin" />
</div>
<CardTitle className="text-xl font-semibold">Verifying...</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
Please wait while we verify your email
</CardDescription>
</>
)}
{status === "success" && (
<>
<div className="mx-auto w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mb-2">
<CheckCircle2 className="h-7 w-7 text-primary" />
</div>
<CardTitle className="text-xl font-semibold">Account Verified!</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{message}
</CardDescription>
</>
)}
{status === "error" && (
<>
<div className="mx-auto w-14 h-14 rounded-full bg-destructive/10 flex items-center justify-center mb-2">
<XCircle className="h-7 w-7 text-destructive" />
</div>
<CardTitle className="text-xl font-semibold">Verification Failed</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{message}
</CardDescription>
</>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{status === "success" && (
<div className="text-center text-sm text-muted-foreground">
Redirecting to dashboard...
</div>
)}
{status === "error" && (
<Button
variant="outline"
onClick={() => {
window.location.href = "/#/login";
}}
className="w-full h-12"
>
Back to Sign In
</Button>
)}
</CardContent>
</Card>
</div>
</div>
);
}

10
package-lock.json generated
View File

@ -60,6 +60,7 @@
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.11",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.21.0", "pg": "^8.21.0",
@ -6619,6 +6620,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

View File

@ -62,6 +62,7 @@
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^8.0.11",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"pg": "^8.21.0", "pg": "^8.21.0",

View File

@ -1,8 +1,10 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
import * as bcrypt from "bcryptjs"; import * as bcrypt from "bcryptjs";
import * as crypto from "crypto";
import type { Express, Request, Response } from "express"; 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 { sanitizeInput } from "./middleware";
import { sendVerificationEmail } from "./email";
export function requireAuth(req: Request, res: Response, next: () => void) { export function requireAuth(req: Request, res: Response, next: () => void) {
const session = req.session as Record<string, unknown>; const session = req.session as Record<string, unknown>;
@ -52,10 +54,19 @@ export function registerAuthRoutes(app: Express) {
passwordHash: hash, passwordHash: hash,
}); });
const session = req.session as Record<string, unknown>; // Generate verification token
session.userId = user.id; 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) { } catch (error) {
console.error("Registration error:", error); console.error("Registration error:", error);
res.status(500).json({ error: "Internal server 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" }); 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<string, unknown>; const session = req.session as Record<string, unknown>;
session.userId = user.id; 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<string, unknown>;
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<string, string>;
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 // POST /api/auth/logout
app.post("/api/auth/logout", (req: Request, res: Response) => { app.post("/api/auth/logout", (req: Request, res: Response) => {
req.session.destroy(() => { req.session.destroy(() => {
@ -109,7 +188,7 @@ export function registerAuthRoutes(app: Express) {
req.session.destroy(); req.session.destroy();
return res.status(401).json({ error: "Not authenticated" }); 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) { } catch (error) {
console.error("Get user error:", error); console.error("Get user error:", error);
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });

View File

@ -26,9 +26,25 @@ export async function initDb(): Promise<void> {
name TEXT NOT NULL, name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, 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() 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<void> {
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<User> { export async function createUser(data: NewUser): Promise<User> {
@ -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(); const result = await db.update(users).set({ passwordHash }).where(eq(users.id, id)).returning();
return result[0]; return result[0];
} }
export async function updateUserVerification(id: number, token: string, expiry: Date): Promise<void> {
await db.update(users).set({ verificationToken: token, verificationTokenExpiry: expiry }).where(eq(users.id, id));
}
export async function getUserByVerificationToken(token: string): Promise<User | undefined> {
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<void> {
await db.update(users).set({ emailVerified: true, verificationToken: null, verificationTokenExpiry: null }).where(eq(users.id, id));
}

54
server/email.ts Normal file
View File

@ -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<boolean> {
const baseUrl = process.env.APP_URL || `http://localhost:${process.env.PORT || 3000}`;
const verifyUrl = `${baseUrl}/verify?token=${token}`;
const mailOptions: Record<string, unknown> = {
from: process.env.SMTP_FROM || `"GammaDesk" <noreply@gammanexus.io>`,
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: `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h1 style="color: #333; margin-bottom: 16px;">Welcome to GammaDesk, ${name}!</h1>
<p style="color: #666; line-height: 1.6; margin-bottom: 24px;">
Thanks for signing up. Please verify your email address by clicking the button below.
</p>
<a href="${verifyUrl}" style="display: inline-block; padding: 12px 24px; background: #22c55e; color: white; text-decoration: none; border-radius: 8px; font-weight: 600;">
Verify Email Address
</a>
<p style="color: #999; font-size: 12px; margin-top: 24px;">
This link will expire in 24 hours.<br/>
If you didn't create this account, you can safely ignore this email.
</p>
</div>
`,
};
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;
}
}

View File

@ -78,9 +78,35 @@ export function securityHeaders(_req: Request, res: Response, next: NextFunction
res.set("X-XSS-Protection", "1; mode=block"); res.set("X-XSS-Protection", "1; mode=block");
res.set("Referrer-Policy", "strict-origin-when-cross-origin"); res.set("Referrer-Policy", "strict-origin-when-cross-origin");
res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); 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( res.set(
"Content-Security-Policy", "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") { if (process.env.NODE_ENV === "production") {

View File

@ -3,7 +3,7 @@
// Database: PostgreSQL // Database: PostgreSQL
import { z } from "zod"; 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) // Auth - Users table (Drizzle + PostgreSQL)
@ -14,6 +14,9 @@ export const users = pgTable("users", {
name: text("name").notNull(), name: text("name").notNull(),
email: text("email").notNull().unique(), email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(), 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(), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}); });