parent
45340d8f85
commit
0f7722e513
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 & education only. Not financial advice. Data is simulated
|
|
||||||
ORATS-style mock data until an API key is provided.
|
|
||||||
</p>
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" });
|
||||||
|
|
|
||||||
35
server/db.ts
35
server/db.ts
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("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") {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue