parent
45340d8f85
commit
bd7b1a35a4
|
|
@ -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 <VerifyPage />;
|
||||
}
|
||||
|
||||
const isAuthPage = location === "/login";
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -78,15 +78,6 @@ export function AppSidebar() {
|
|||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</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 & education only. Not financial advice. Data is simulated
|
||||
ORATS-style mock data until an API key is provided.
|
||||
</p>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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>;
|
||||
updateProfile: (data: { name?: string; email?: string }) => Promise<void>;
|
||||
changePassword: (current: string, newPass: string) => Promise<void>;
|
||||
requestReset: (email: string) => Promise<void>;
|
||||
resendVerification: (email: string) => Promise<void>;
|
||||
error: string | null;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
|
@ -41,17 +43,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
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) => {
|
||||
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 (
|
||||
<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}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 (
|
||||
|
|
@ -64,7 +76,27 @@ export default function LoginPage() {
|
|||
</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">
|
||||
<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>
|
||||
|
|
@ -128,6 +160,7 @@ export default function LoginPage() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="register">
|
||||
{!regSuccess ? (
|
||||
<Card className="border-border bg-card/50 backdrop-blur-sm shadow-xl">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl font-semibold">Create your account</CardTitle>
|
||||
|
|
@ -168,9 +201,9 @@ export default function LoginPage() {
|
|||
type="password"
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
placeholder="Min 6 characters"
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="h-12 bg-background border-border focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
|
|
@ -187,13 +220,36 @@ export default function LoginPage() {
|
|||
</form>
|
||||
</CardContent>
|
||||
</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>
|
||||
</Tabs>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-8">
|
||||
Analytics & education only. Not financial advice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
|
@ -52,10 +54,19 @@ export function registerAuthRoutes(app: Express) {
|
|||
passwordHash: hash,
|
||||
});
|
||||
|
||||
const session = req.session as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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
|
||||
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" });
|
||||
|
|
|
|||
35
server/db.ts
35
server/db.ts
|
|
@ -26,9 +26,25 @@ export async function initDb(): Promise<void> {
|
|||
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<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> {
|
||||
|
|
@ -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<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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue