release: alpha2.0 — session persistence, Tanuki stability, login flow

Auth & Session:
- Add credentials: include to all frontend fetch calls (queryClient,
  auth-context, tanuki-trades, symbol-selector, settings, verify)
- Redis-backed session store with connect-redis, 7-day cookie expiry,
  sameSite=lax, httpOnly=true
- Add app.set('trust proxy', 1) so Express honors X-Forwarded-Proto from
  nginx — fixes secure cookies never being sent over HTTPS
- Cookie secure flag also checks APP_URL for reverse proxy deployments

Login Flow:
- Fix post-login redirect race condition: use full page reload
  (window.location.href) instead of hash navigation so auth cookie is
  established before ProtectedRoute checks session
- Disable SymbolSelector query when not authenticated to prevent blocking
  fetches on login page
- SymbolSelector returns null when not authenticated — no UI rendered

Tanuki Trades:
- Add ErrorBoundary component to isolate Tanuki page crashes
- Use unique queryKey ['tanuki-tickers'] to prevent React Query cache
  collision with symbol selector
- Defensive query syntax with async/await for error isolation

Testing:
- Verified login → redirect → refresh → session persists (no 401)
- Verified Tanuki page no longer crashes the entire app
- Verified /api/auth/me returns user data across requests with cookie
master alpha2.0
isnowglobal-admin 2026-07-01 14:53:24 -04:00
parent 14d079bb35
commit fd40f75897
11 changed files with 113 additions and 22 deletions

View File

@ -18,6 +18,7 @@ import ScreenerPage from "@/pages/screener";
import FuturesLevelsPage from "@/pages/futures-levels"; import FuturesLevelsPage from "@/pages/futures-levels";
import TanukiTradesPage from "@/pages/tanuki-trades"; import TanukiTradesPage from "@/pages/tanuki-trades";
import SettingsPage from "@/pages/settings"; import SettingsPage from "@/pages/settings";
import { ErrorBoundary } from "@/components/error-boundary";
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 VerifyPage from "@/pages/verify";
@ -96,7 +97,11 @@ function AppShell() {
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} /> <Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} /> <Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
<Route path="/futures" component={() => <ProtectedRoute component={FuturesLevelsPage} />} /> <Route path="/futures" component={() => <ProtectedRoute component={FuturesLevelsPage} />} />
<Route path="/tanuki" component={() => <ProtectedRoute component={TanukiTradesPage} />} /> <Route path="/tanuki" component={() => <ProtectedRoute component={() => (
<ErrorBoundary>
<TanukiTradesPage />
</ErrorBoundary>
)} />} />
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} /> <Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>

View File

@ -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<Props, State> {
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 (
<Card className="m-4">
<CardContent className="flex flex-col items-center justify-center gap-4 p-8 text-center">
<AlertTriangle className="h-10 w-10 text-destructive" />
<div>
<h3 className="text-sm font-semibold">Something went wrong</h3>
<p className="text-xs text-muted-foreground mt-1">
{this.state.error?.message || "An unexpected error occurred"}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => this.setState({ hasError: false, error: null })}
>
<RefreshCw className="h-3 w-3 mr-1" />
Try again
</Button>
</CardContent>
</Card>
);
}
return this.props.children;
}
}

View File

@ -26,7 +26,10 @@ export function SymbolSelector() {
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const isAuthenticated = !!user; const isAuthenticated = !!user;
const { data } = useQuery<SymbolInfo[]>({ queryKey: ["/api/symbols"] }); const { data } = useQuery<SymbolInfo[]>({
queryKey: ["/api/symbols"],
enabled: isAuthenticated,
});
const symbols = data ?? []; const symbols = data ?? [];
const builtInSymbols = symbols.filter((s) => !s.custom); const builtInSymbols = symbols.filter((s) => !s.custom);
const customSymbols = symbols.filter((s) => s.custom); const customSymbols = symbols.filter((s) => s.custom);
@ -37,6 +40,7 @@ export function SymbolSelector() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
@ -54,7 +58,7 @@ export function SymbolSelector() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (ticker: string) => { 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) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
throw new Error(err.error || "Failed to delete symbol"); throw new Error(err.error || "Failed to delete symbol");

View File

@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
useEffect(() => { useEffect(() => {
fetch("/api/auth/me") fetch("/api/auth/me", { credentials: "include" })
.then((r) => { .then((r) => {
if (r.ok) return r.json(); if (r.ok) return r.json();
throw new Error("not authenticated"); throw new Error("not authenticated");
@ -49,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Authentication failed" })); 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(); const data: User = await res.json();
setUser(data); setUser(data);
// Use full page navigation to dashboard - bypasses hash router issues // Full page reload to ensure auth state is established before ProtectedRoute runs
window.location.hash = "/"; await new Promise(resolve => setTimeout(resolve, 100));
window.location.href = "/#/";
}; };
const register = async (name: string, email: string, password: string) => { const register = async (name: string, email: string, password: string) => {
@ -67,6 +69,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }), body: JSON.stringify({ name, email, password }),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Registration failed" })); const err = await res.json().catch(() => ({ error: "Registration failed" }));
@ -79,7 +82,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
const logout = async () => { const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" }); await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
setUser(null); setUser(null);
setLocation("/login"); setLocation("/login");
}; };
@ -90,6 +93,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Update failed" })); const err = await res.json().catch(() => ({ error: "Update failed" }));
@ -107,6 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword: current, newPassword: newPass }), body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Password change failed" })); const err = await res.json().catch(() => ({ error: "Password change failed" }));
@ -123,6 +128,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Request failed" })); const err = await res.json().catch(() => ({ error: "Request failed" }));
@ -139,6 +145,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }), body: JSON.stringify({ email }),
credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to resend" })); const err = await res.json().catch(() => ({ error: "Failed to resend" }));

View File

@ -18,6 +18,7 @@ export async function apiRequest(
method, method,
headers: data ? { "Content-Type": "application/json" } : {}, headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined, body: data ? JSON.stringify(data) : undefined,
credentials: "include",
}); });
await throwIfResNotOk(res); await throwIfResNotOk(res);
@ -30,7 +31,9 @@ export const getQueryFn: <T>(options: {
}) => QueryFunction<T> = }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { 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) { if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null; return null;

View File

@ -24,9 +24,10 @@ export default function SettingsPage() {
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (key: string) => { mutationFn: async (key: string) => {
const res = await fetch("/api/orats/config", { const res = await fetch("/api/orats/config", {
method: "POST", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: key, baseUrl }), body: JSON.stringify({ apiKey: key, baseUrl }),
credentials: "include",
}); });
if (!res.ok) throw new Error("Failed to save API key"); if (!res.ok) throw new Error("Failed to save API key");
return res.json(); return res.json();
@ -40,7 +41,7 @@ export default function SettingsPage() {
const clearMutation = useMutation({ const clearMutation = useMutation({
mutationFn: async () => { 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"); if (!res.ok) throw new Error("Failed to clear API key");
return res.json(); return res.json();
}, },

View File

@ -44,8 +44,8 @@ export default function TanukiTradesPage() {
// Fetch all available symbols // Fetch all available symbols
const symbolsQuery = useQuery<string[]>({ const symbolsQuery = useQuery<string[]>({
queryKey: ["/api/symbols"], queryKey: ["/api/symbols/tickers"],
queryFn: () => fetch("/api/symbols").then(r => r.json()).then((data: any[]) => queryFn: () => fetch("/api/symbols", { credentials: "include" }).then(r => r.json()).then((data: any[]) =>
data.map((s: any) => s.ticker) data.map((s: any) => s.ticker)
), ),
staleTime: 60 * 1000, staleTime: 60 * 1000,
@ -64,18 +64,24 @@ export default function TanukiTradesPage() {
// Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured) // Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured)
const tanukiQuery = useQuery<TanukiLevels>({ const tanukiQuery = useQuery<TanukiLevels>({
queryKey: ["/api/tanuki", selectedSymbol], queryKey: ["/api/tanuki", selectedSymbol],
queryFn: () => fetch(`/api/tanuki/${selectedSymbol}`).then(r => { queryFn: async () => {
if (!r.ok) throw new Error(r.statusText); 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(); return r.json();
}), },
enabled: !compareMode, enabled: !compareMode && !!selectedSymbol,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
}); });
// Multi-symbol comparison query — uses all symbols // Multi-symbol comparison query — uses all symbols
const compareQuery = useQuery<Record<string, TanukiLevels | { error: string }>>({ const compareQuery = useQuery<Record<string, TanukiLevels | { error: string }>>({
queryKey: ["/api/tanuki", "compare"], 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); if (!r.ok) throw new Error(r.statusText);
return r.json(); return r.json();
}), }),
@ -86,14 +92,14 @@ export default function TanukiTradesPage() {
// Status query // Status query
const statusQuery = useQuery({ const statusQuery = useQuery({
queryKey: ["/api/tanuki/status"], 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, staleTime: 60 * 1000,
}); });
const tanukiConfigured = statusQuery.data?.configured; const tanukiConfigured = statusQuery.data?.configured;
const oratsStatusQuery = useQuery({ const oratsStatusQuery = useQuery({
queryKey: ["/api/orats/status"], 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, staleTime: 60 * 1000,
}); });
const oratsConfigured = oratsStatusQuery.data?.configured; const oratsConfigured = oratsStatusQuery.data?.configured;

View File

@ -17,7 +17,7 @@ export default function VerifyPage() {
return; return;
} }
fetch(`/api/auth/verify/${token}`) fetch(`/api/auth/verify/${token}`, { credentials: "include" })
.then(async (res) => { .then(async (res) => {
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {

View File

@ -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( app.use(
express.json({ express.json({
verify: (req, _res, buf) => { verify: (req, _res, buf) => {

View File

@ -95,7 +95,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { 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 httpOnly: true, // Prevent XSS from accessing session cookie
sameSite: "lax", // Allow same-site + top-level navigation sameSite: "lax", // Allow same-site + top-level navigation
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
@ -106,7 +106,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000, maxAge: 7 * 24 * 60 * 60 * 1000,

3
start-dev.sh Normal file
View File

@ -0,0 +1,3 @@
#!/bin/bash
cd /home/chuck/projects/gammadesk
NODE_ENV=development npx tsx server/index.ts "$@"