import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { PlugZap, ArrowUpRight, ShieldCheck, RefreshCcw } from "lucide-react"; import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { toast } from "@/hooks/use-toast"; import { apiRequest, getQueryFn } from "@/lib/queryClient"; import type { User } from "@shared/schema"; import LoginRequiredDialog from "./LoginRequiredDialog"; type BrokerConnectDialogProps = { layout?: "desktop" | "mobile"; open?: boolean; onOpenChange?: (open: boolean) => void; }; type SessionUser = Pick; type BrokerStatusResponse = { connected: boolean; broker?: string; connected_at?: string; userName?: string; brokerUserId?: string; authState?: string; }; const CALLBACK_STORAGE_KEY = "zerodha:callback"; const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000; export default function BrokerConnectDialog({ layout = "desktop", open, onOpenChange, }: BrokerConnectDialogProps) { const [connectOpenInternal, setConnectOpenInternal] = useState(false); const isControlled = open !== undefined; const connectOpen = isControlled ? open : connectOpenInternal; const setConnectOpen = (nextOpen: boolean) => { if (!isControlled) { setConnectOpenInternal(nextOpen); } onOpenChange?.(nextOpen); }; const [loginPromptOpen, setLoginPromptOpen] = useState(false); const [apiKey, setApiKey] = useState(""); const [apiSecret, setApiSecret] = useState(""); const [holdings, setHoldings] = useState([]); const { data: sessionUser, refetch: refetchSessionUser } = useQuery({ queryKey: ["/me"], queryFn: getQueryFn({ on401: "returnNull" }), staleTime: 0, refetchOnMount: "always", }); const { data: brokerStatus, refetch: refetchStatus } = useQuery({ queryKey: ["/broker/status"], queryFn: getQueryFn({ on401: "returnNull" }), staleTime: 0, refetchOnMount: "always", }); const loginUrlMutation = useMutation({ mutationFn: async () => { if (!apiKey.trim()) { throw new Error("API key is required"); } if (!apiSecret.trim()) { throw new Error("API secret is required"); } const redirectUrl = `${window.location.origin}/login`; const res = await apiRequest("POST", "/broker/zerodha/login", { apiKey, apiSecret, redirectUrl, }); return res.json() as Promise<{ loginUrl: string }>; }, onSuccess: ({ loginUrl }) => { window.open(loginUrl, "_blank", "noopener,noreferrer"); toast({ title: "Continue in Zerodha", description: "Log in and return here. We will connect your broker automatically.", }); }, onError: (err: any) => toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }), }); const reconnectSavedMutation = useMutation({ mutationFn: async () => { const res = await apiRequest("GET", "/broker/login-url"); return res.json() as Promise<{ loginUrl: string }>; }, onSuccess: ({ loginUrl }) => { window.open(loginUrl, "_blank", "noopener,noreferrer"); toast({ title: "Continue in Zerodha", description: "Log in and return here. We will reconnect your broker automatically.", }); }, onError: (err: any) => { const message = String(err?.message || ""); if (message.includes("400:") && message.includes("Broker credentials not configured")) { toast({ title: "Enter Zerodha API credentials", description: "Saved credentials are missing. Enter the API key and secret once to reconnect.", }); return; } toast({ title: "Could not reconnect Zerodha", description: err?.message || "Try again.", }); }, }); const holdingsMutation = useMutation({ mutationFn: async () => { const res = await apiRequest("GET", "/zerodha/holdings"); return res.json() as Promise<{ holdings: any[] }>; }, onSuccess: (data) => { setHoldings(data?.holdings || []); toast({ title: "Holdings fetched", description: "Latest positions pulled from Zerodha." }); }, onError: (err: any) => toast({ title: "Could not fetch holdings", description: err?.message || "Check your Zerodha session and try again.", }), }); const disconnectMutation = useMutation({ mutationFn: async () => { const res = await apiRequest("POST", "/broker/disconnect"); return res.json() as Promise<{ connected: boolean }>; }, onSuccess: () => { refetchStatus(); toast({ title: "Broker disconnected", description: "Your broker has been unlinked." }); }, onError: (err: any) => toast({ title: "Disconnect failed", description: err?.message || "Try again.", }), }); const connected = !!brokerStatus?.connected; const canReconnectWithSavedZerodha = connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA"; const connectedAt = brokerStatus?.connected_at ? new Date(brokerStatus.connected_at) : null; const triggerClassName = useMemo(() => { const layoutClasses = layout === "mobile" ? "w-full justify-center shimmer" : "px-4 rounded-xl shimmer"; return connected ? `${layoutClasses} disabled:opacity-100 disabled:cursor-default` : layoutClasses; }, [connected, layout]); const handleConnectClick = async () => { if (connected) { return; } const latest = await refetchSessionUser(); if (!latest.data) { setLoginPromptOpen(true); return; } setConnectOpen(true); }; useEffect(() => { const consumeCallback = (rawValue?: string) => { const value = rawValue || localStorage.getItem(CALLBACK_STORAGE_KEY); if (!value) return; localStorage.removeItem(CALLBACK_STORAGE_KEY); try { const payload = JSON.parse(value) as { status?: "success" | "error"; message?: string; ts?: number; }; if (payload.ts && Date.now() - payload.ts > CALLBACK_MAX_AGE_MS) { return; } if (payload.status === "success") { refetchStatus(); toast({ title: "Zerodha connected", description: "Your broker connection has been completed.", }); setConnectOpen(false); } else if (payload.status === "error") { toast({ title: "Zerodha login failed", description: payload.message || "Please retry the login.", }); } } catch { return; } }; consumeCallback(); const handleStorage = (event: StorageEvent) => { if (event.key !== CALLBACK_STORAGE_KEY || !event.newValue) { return; } consumeCallback(event.newValue); }; window.addEventListener("storage", handleStorage); return () => window.removeEventListener("storage", handleStorage); }, [refetchStatus]); return ( <> Connect your broker Link your brokerage to pull positions and keep your dashboard in sync.

Secure brokerage linking

Start in Zerodha and we will complete the connection automatically.

{connected ? "Connected" : "Not connected"}
{connected && (
{brokerStatus?.userName ? `Linked as ${brokerStatus.userName}` : `Linked to ${brokerStatus?.broker || "broker"}`}{" "} -{" "} {connectedAt ? connectedAt.toLocaleString() : "just now"}
)}

Zerodha

Provide your Kite API key & secret to launch the login and connect your account.

Live
setApiKey(e.target.value)} />
setApiSecret(e.target.value)} />
{canReconnectWithSavedZerodha ? ( ) : null} {connected ? ( ) : null} {connected ? ( ) : null}

After you log in with your Zerodha account, we will connect automatically. Keep this tab open until the login completes.

Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key and secret without re-entering them.

Other brokers

Groww (coming soon) Angel One (coming soon) ICICI Direct (coming soon) HDFC Securities (coming soon)
{connected && (

Latest holdings

{holdings.length === 0 ? (

No holdings pulled yet. Click “Fetch holdings” after connecting.

) : (
{holdings.map((item, idx) => (

{item.tradingsymbol || item.symbol || "Instrument"}

Qty: {item.quantity ?? item.qty ?? "-"} | Avg:{" "} {item.average_price ?? item.avg_price ?? "-"}

{item.exchange || item.exchange_type || "N/A"}
))}
)}
)}
); }