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
parent
14d079bb35
commit
fd40f75897
|
|
@ -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() {
|
|||
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
|
||||
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
|
||||
<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 component={NotFound} />
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,10 @@ export function SymbolSelector() {
|
|||
const [showAdd, setShowAdd] = useState(false);
|
||||
const isAuthenticated = !!user;
|
||||
|
||||
const { data } = useQuery<SymbolInfo[]>({ queryKey: ["/api/symbols"] });
|
||||
const { data } = useQuery<SymbolInfo[]>({
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
|
|
|
|||
|
|
@ -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: <T>(options: {
|
|||
}) => QueryFunction<T> =
|
||||
({ 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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export default function TanukiTradesPage() {
|
|||
|
||||
// Fetch all available symbols
|
||||
const symbolsQuery = useQuery<string[]>({
|
||||
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<TanukiLevels>({
|
||||
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<Record<string, TanukiLevels | { error: string }>>({
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/bash
|
||||
cd /home/chuck/projects/gammadesk
|
||||
NODE_ENV=development npx tsx server/index.ts "$@"
|
||||
Loading…
Reference in New Issue