diff --git a/client/src/App.tsx b/client/src/App.tsx index 5290a3f..7db394a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,6 +18,7 @@ import ScreenerPage from "@/pages/screener"; import FuturesLevelsPage from "@/pages/futures-levels"; import TanukiTradesPage from "@/pages/tanuki-trades"; import SettingsPage from "@/pages/settings"; +import { ErrorBoundary } from "@/components/error-boundary"; import AccountPage from "@/pages/account"; import LoginPage from "@/pages/login"; import VerifyPage from "@/pages/verify"; @@ -96,7 +97,11 @@ function AppShell() { } /> } /> } /> - } /> + ( + + + + )} />} /> } /> diff --git a/client/src/components/error-boundary.tsx b/client/src/components/error-boundary.tsx new file mode 100644 index 0000000..b6bb1ee --- /dev/null +++ b/client/src/components/error-boundary.tsx @@ -0,0 +1,59 @@ +import { Component, ErrorInfo, ReactNode } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("[ErrorBoundary] Caught:", error, errorInfo.componentStack); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ( + + + +
+

Something went wrong

+

+ {this.state.error?.message || "An unexpected error occurred"} +

+
+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/client/src/components/symbol-selector.tsx b/client/src/components/symbol-selector.tsx index 0e86213..7bdd3cb 100644 --- a/client/src/components/symbol-selector.tsx +++ b/client/src/components/symbol-selector.tsx @@ -26,7 +26,10 @@ export function SymbolSelector() { const [showAdd, setShowAdd] = useState(false); const isAuthenticated = !!user; - const { data } = useQuery({ queryKey: ["/api/symbols"] }); + const { data } = useQuery({ + queryKey: ["/api/symbols"], + enabled: isAuthenticated, + }); const symbols = data ?? []; const builtInSymbols = symbols.filter((s) => !s.custom); const customSymbols = symbols.filter((s) => s.custom); @@ -37,6 +40,7 @@ export function SymbolSelector() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + credentials: "include", }); if (!res.ok) { const err = await res.json(); @@ -54,7 +58,7 @@ export function SymbolSelector() { const deleteMutation = useMutation({ mutationFn: async (ticker: string) => { - const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE" }); + const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE", credentials: "include" }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to delete symbol"); diff --git a/client/src/lib/auth-context.tsx b/client/src/lib/auth-context.tsx index bd9fb39..2f865d3 100644 --- a/client/src/lib/auth-context.tsx +++ b/client/src/lib/auth-context.tsx @@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [, setLocation] = useLocation(); useEffect(() => { - fetch("/api/auth/me") + fetch("/api/auth/me", { credentials: "include" }) .then((r) => { if (r.ok) return r.json(); throw new Error("not authenticated"); @@ -49,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), + credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Authentication failed" })); @@ -57,8 +58,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { } const data: User = await res.json(); setUser(data); - // Use full page navigation to dashboard - bypasses hash router issues - window.location.hash = "/"; + // Full page reload to ensure auth state is established before ProtectedRoute runs + await new Promise(resolve => setTimeout(resolve, 100)); + window.location.href = "/#/"; }; const register = async (name: string, email: string, password: string) => { @@ -67,6 +69,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, email, password }), + credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Registration failed" })); @@ -79,7 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; const logout = async () => { - await fetch("/api/auth/logout", { method: "POST" }); + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); setUser(null); setLocation("/login"); }; @@ -90,6 +93,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), + credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Update failed" })); @@ -107,6 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword: current, newPassword: newPass }), + credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Password change failed" })); @@ -123,6 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), + credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); @@ -139,6 +145,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), + credentials: "include", }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Failed to resend" })); diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 93c882a..cb99ffa 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -18,6 +18,7 @@ export async function apiRequest( method, headers: data ? { "Content-Type": "application/json" } : {}, body: data ? JSON.stringify(data) : undefined, + credentials: "include", }); await throwIfResNotOk(res); @@ -30,7 +31,9 @@ export const getQueryFn: (options: { }) => QueryFunction = ({ on401: unauthorizedBehavior }) => async ({ queryKey }) => { - const res = await fetch(`${API_BASE}${queryKey.join("/")}`); + const res = await fetch(`${API_BASE}${queryKey.join("/")}`, { + credentials: "include", + }); if (unauthorizedBehavior === "returnNull" && res.status === 401) { return null; diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx index f7b074d..700d1a7 100644 --- a/client/src/pages/settings.tsx +++ b/client/src/pages/settings.tsx @@ -24,9 +24,10 @@ export default function SettingsPage() { const saveMutation = useMutation({ mutationFn: async (key: string) => { const res = await fetch("/api/orats/config", { - method: "POST", + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: key, baseUrl }), + credentials: "include", }); if (!res.ok) throw new Error("Failed to save API key"); return res.json(); @@ -40,7 +41,7 @@ export default function SettingsPage() { const clearMutation = useMutation({ mutationFn: async () => { - const res = await fetch("/api/orats/config", { method: "DELETE" }); + const res = await fetch("/api/orats/config", { method: "DELETE", credentials: "include" }); if (!res.ok) throw new Error("Failed to clear API key"); return res.json(); }, diff --git a/client/src/pages/tanuki-trades.tsx b/client/src/pages/tanuki-trades.tsx index 07c8257..c089ec5 100644 --- a/client/src/pages/tanuki-trades.tsx +++ b/client/src/pages/tanuki-trades.tsx @@ -44,8 +44,8 @@ export default function TanukiTradesPage() { // Fetch all available symbols const symbolsQuery = useQuery({ - queryKey: ["/api/symbols"], - queryFn: () => fetch("/api/symbols").then(r => r.json()).then((data: any[]) => + queryKey: ["/api/symbols/tickers"], + queryFn: () => fetch("/api/symbols", { credentials: "include" }).then(r => r.json()).then((data: any[]) => data.map((s: any) => s.ticker) ), staleTime: 60 * 1000, @@ -64,18 +64,24 @@ export default function TanukiTradesPage() { // Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured) const tanukiQuery = useQuery({ queryKey: ["/api/tanuki", selectedSymbol], - queryFn: () => fetch(`/api/tanuki/${selectedSymbol}`).then(r => { - if (!r.ok) throw new Error(r.statusText); + queryFn: async () => { + const r = await fetch(`/api/tanuki/${selectedSymbol}`, { credentials: "include" }); + if (!r.ok) { + if (r.status === 404) { + return await r.json(); + } + throw new Error(`Tanuki fetch failed: ${r.status}`); + } return r.json(); - }), - enabled: !compareMode, + }, + enabled: !compareMode && !!selectedSymbol, staleTime: 2 * 60 * 1000, }); // Multi-symbol comparison query — uses all symbols const compareQuery = useQuery>({ queryKey: ["/api/tanuki", "compare"], - queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`).then(r => { + queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`, { credentials: "include" }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }), @@ -86,14 +92,14 @@ export default function TanukiTradesPage() { // Status query const statusQuery = useQuery({ queryKey: ["/api/tanuki/status"], - queryFn: () => fetch("/api/tanuki/status").then(r => r.json()), + queryFn: () => fetch("/api/tanuki/status", { credentials: "include" }).then(r => r.json()), staleTime: 60 * 1000, }); const tanukiConfigured = statusQuery.data?.configured; const oratsStatusQuery = useQuery({ queryKey: ["/api/orats/status"], - queryFn: () => fetch("/api/orats/status").then(r => r.json()), + queryFn: () => fetch("/api/orats/status", { credentials: "include" }).then(r => r.json()), staleTime: 60 * 1000, }); const oratsConfigured = oratsStatusQuery.data?.configured; diff --git a/client/src/pages/verify.tsx b/client/src/pages/verify.tsx index 36b87bb..b2256d1 100644 --- a/client/src/pages/verify.tsx +++ b/client/src/pages/verify.tsx @@ -17,7 +17,7 @@ export default function VerifyPage() { return; } - fetch(`/api/auth/verify/${token}`) + fetch(`/api/auth/verify/${token}`, { credentials: "include" }) .then(async (res) => { const data = await res.json(); if (!res.ok) { diff --git a/server/index.ts b/server/index.ts index 31dee71..001330a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,6 +14,9 @@ declare module "http" { } } +// Trust reverse proxy (nginx) for protocol/headers so session cookies work over HTTPS +app.set("trust proxy", 1); + app.use( express.json({ verify: (req, _res, buf) => { diff --git a/server/routes.ts b/server/routes.ts index 4b71242..43bb47f 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -95,7 +95,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< resave: false, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === "production", + secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"), httpOnly: true, // Prevent XSS from accessing session cookie sameSite: "lax", // Allow same-site + top-level navigation maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days @@ -106,7 +106,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< resave: false, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === "production", + secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"), httpOnly: true, sameSite: "lax", maxAge: 7 * 24 * 60 * 60 * 1000, diff --git a/start-dev.sh b/start-dev.sh new file mode 100644 index 0000000..514140f --- /dev/null +++ b/start-dev.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/chuck/projects/gammadesk +NODE_ENV=development npx tsx server/index.ts "$@"