Add Groww broker support to portfolio UI
This commit is contained in:
parent
71a99b7b0a
commit
56f6e8551e
@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { PlugZap, ArrowUpRight, ShieldCheck, RefreshCcw } from "lucide-react";
|
||||
import { ArrowUpRight, PlugZap, RefreshCcw, ShieldCheck } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
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";
|
||||
@ -36,6 +36,11 @@ type BrokerStatusResponse = {
|
||||
authState?: string;
|
||||
};
|
||||
|
||||
type HoldingsResponse = {
|
||||
broker?: string;
|
||||
holdings?: any[];
|
||||
};
|
||||
|
||||
const CALLBACK_STORAGE_KEY = "zerodha:callback";
|
||||
const CALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
|
||||
@ -45,6 +50,13 @@ function buildReconnectRedirectUrl() {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function formatBrokerName(broker?: string | null) {
|
||||
const normalized = (broker || "").trim().toUpperCase();
|
||||
if (normalized === "GROWW") return "Groww";
|
||||
if (normalized === "ZERODHA") return "Zerodha";
|
||||
return normalized || "broker";
|
||||
}
|
||||
|
||||
export default function BrokerConnectDialog({
|
||||
layout = "desktop",
|
||||
open,
|
||||
@ -61,8 +73,10 @@ export default function BrokerConnectDialog({
|
||||
onOpenChange?.(nextOpen);
|
||||
};
|
||||
const [loginPromptOpen, setLoginPromptOpen] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [apiSecret, setApiSecret] = useState("");
|
||||
const [zerodhaApiKey, setZerodhaApiKey] = useState("");
|
||||
const [zerodhaApiSecret, setZerodhaApiSecret] = useState("");
|
||||
const [growwApiKey, setGrowwApiKey] = useState("");
|
||||
const [growwApiSecret, setGrowwApiSecret] = useState("");
|
||||
const [holdings, setHoldings] = useState<any[]>([]);
|
||||
|
||||
const { data: sessionUser, refetch: refetchSessionUser } = useQuery<SessionUser | null>({
|
||||
@ -79,21 +93,36 @@ export default function BrokerConnectDialog({
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const loginUrlMutation = useMutation({
|
||||
const connected = !!brokerStatus?.connected;
|
||||
const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase();
|
||||
const brokerLabel = formatBrokerName(connectedBroker);
|
||||
const connectedAt = brokerStatus?.connected_at ? new Date(brokerStatus.connected_at) : null;
|
||||
const canReconnectWithSavedZerodha = connected && connectedBroker === "ZERODHA";
|
||||
const canReconnectWithSavedGroww = connected && connectedBroker === "GROWW";
|
||||
|
||||
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 zerodhaLoginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!apiKey.trim()) {
|
||||
throw new Error("API key is required");
|
||||
if (!zerodhaApiKey.trim()) {
|
||||
throw new Error("Zerodha API key is required");
|
||||
}
|
||||
if (!apiSecret.trim()) {
|
||||
throw new Error("API secret is required");
|
||||
if (!zerodhaApiSecret.trim()) {
|
||||
throw new Error("Zerodha API secret is required");
|
||||
}
|
||||
const redirectUrl = `${window.location.origin}/login`;
|
||||
const res = await apiRequest("POST", "/broker/zerodha/login", {
|
||||
apiKey,
|
||||
apiSecret,
|
||||
apiKey: zerodhaApiKey,
|
||||
apiSecret: zerodhaApiSecret,
|
||||
redirectUrl,
|
||||
});
|
||||
return res.json() as Promise<{ loginUrl: string }>;
|
||||
return (await res.json()) as { loginUrl: string };
|
||||
},
|
||||
onSuccess: ({ loginUrl }) => {
|
||||
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
||||
@ -103,15 +132,18 @@ export default function BrokerConnectDialog({
|
||||
});
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast({ title: "Could not start Zerodha login", description: err?.message || "Try again." }),
|
||||
toast({
|
||||
title: "Could not start Zerodha login",
|
||||
description: err?.message || "Try again.",
|
||||
}),
|
||||
});
|
||||
|
||||
const reconnectSavedMutation = useMutation({
|
||||
const zerodhaReconnectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const redirectUrl = buildReconnectRedirectUrl();
|
||||
const params = new URLSearchParams({ redirectUrl });
|
||||
const res = await apiRequest("GET", `/broker/login-url?${params.toString()}`);
|
||||
return res.json() as Promise<{ loginUrl: string }>;
|
||||
return (await res.json()) as { loginUrl: string };
|
||||
},
|
||||
onSuccess: ({ loginUrl }) => {
|
||||
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
||||
@ -136,30 +168,107 @@ export default function BrokerConnectDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const growwConnectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!growwApiKey.trim()) {
|
||||
throw new Error("Groww API key is required");
|
||||
}
|
||||
if (!growwApiSecret.trim()) {
|
||||
throw new Error("Groww API secret is required");
|
||||
}
|
||||
const res = await apiRequest("POST", "/broker/groww/connect", {
|
||||
apiKey: growwApiKey,
|
||||
apiSecret: growwApiSecret,
|
||||
});
|
||||
return (await res.json()) as {
|
||||
connected?: boolean;
|
||||
broker?: string;
|
||||
userName?: string;
|
||||
};
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await refetchStatus();
|
||||
setConnectOpen(false);
|
||||
toast({
|
||||
title: "Groww connected",
|
||||
description:
|
||||
data?.userName
|
||||
? `Connected as ${data.userName}.`
|
||||
: "Your Groww broker session is now active.",
|
||||
});
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast({
|
||||
title: "Could not connect Groww",
|
||||
description: err?.message || "Try again.",
|
||||
}),
|
||||
});
|
||||
|
||||
const growwReconnectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/broker/groww/reconnect");
|
||||
return (await res.json()) as {
|
||||
connected?: boolean;
|
||||
broker?: string;
|
||||
userName?: string;
|
||||
};
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await refetchStatus();
|
||||
toast({
|
||||
title: "Groww reconnected",
|
||||
description:
|
||||
data?.userName
|
||||
? `Session refreshed for ${data.userName}.`
|
||||
: "Your Groww broker session has been refreshed.",
|
||||
});
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const message = String(err?.message || "");
|
||||
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
|
||||
toast({
|
||||
title: "Enter Groww API credentials",
|
||||
description: "Saved credentials are missing. Enter the API key and secret once to reconnect.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Could not reconnect Groww",
|
||||
description: err?.message || "Try again.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const holdingsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("GET", "/zerodha/holdings");
|
||||
return res.json() as Promise<{ holdings: any[] }>;
|
||||
const res = await apiRequest("GET", "/broker/holdings");
|
||||
return (await res.json()) as HoldingsResponse;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setHoldings(data?.holdings || []);
|
||||
toast({ title: "Holdings fetched", description: "Latest positions pulled from Zerodha." });
|
||||
toast({
|
||||
title: "Holdings fetched",
|
||||
description: `Latest positions pulled from ${formatBrokerName(data?.broker || brokerStatus?.broker)}.`,
|
||||
});
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast({
|
||||
title: "Could not fetch holdings",
|
||||
description: err?.message || "Check your Zerodha session and try again.",
|
||||
description: err?.message || "Check your broker session and try again.",
|
||||
}),
|
||||
});
|
||||
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/broker/disconnect");
|
||||
return res.json() as Promise<{ connected: boolean }>;
|
||||
return (await res.json()) as { connected: boolean };
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchStatus();
|
||||
toast({ title: "Broker disconnected", description: "Your broker has been unlinked." });
|
||||
onSuccess: async () => {
|
||||
await refetchStatus();
|
||||
toast({
|
||||
title: "Broker disconnected",
|
||||
description: "Your broker has been unlinked.",
|
||||
});
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast({
|
||||
@ -168,20 +277,6 @@ export default function BrokerConnectDialog({
|
||||
}),
|
||||
});
|
||||
|
||||
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;
|
||||
@ -244,7 +339,7 @@ export default function BrokerConnectDialog({
|
||||
<>
|
||||
<Dialog open={connectOpen} onOpenChange={setConnectOpen}>
|
||||
<Button
|
||||
variant={connected ? "secondary" : "secondary"}
|
||||
variant="secondary"
|
||||
className={[triggerClassName, triggerClassNameProp].filter(Boolean).join(" ")}
|
||||
disabled={connected}
|
||||
onClick={handleConnectClick}
|
||||
@ -252,7 +347,7 @@ export default function BrokerConnectDialog({
|
||||
<PlugZap className="h-4 w-4" />
|
||||
{connected ? "Broker connected" : "Connect broker"}
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-2xl border-border/70 bg-gradient-to-br from-background via-background to-muted/30">
|
||||
<DialogContent className="sm:max-w-3xl border-border/70 bg-gradient-to-br from-background via-background to-muted/30">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@ -267,173 +362,239 @@ export default function BrokerConnectDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card/70 p-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-gradient-to-r from-primary/10 via-background to-background px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-primary/15 p-2 text-primary">
|
||||
<PlugZap className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-sm leading-tight">
|
||||
<p className="font-medium">Secure brokerage linking</p>
|
||||
<p className="text-muted-foreground">
|
||||
Start in Zerodha and we will complete the connection automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={connected ? "secondary" : "outline"} className="ml-auto">
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
</Badge>
|
||||
</div>
|
||||
{connected && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<span>
|
||||
{brokerStatus?.userName
|
||||
? `Linked as ${brokerStatus.userName}`
|
||||
: `Linked to ${brokerStatus?.broker || "broker"}`}{" "}
|
||||
-{" "}
|
||||
{connectedAt
|
||||
? connectedAt.toLocaleString()
|
||||
: "just now"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 shadow-sm"
|
||||
whileHover={{
|
||||
y: -6,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 24 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Zerodha</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide your Kite API key & secret to launch the login and connect your account.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Live</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zerodha-api-key">API key</Label>
|
||||
<Input
|
||||
id="zerodha-api-key"
|
||||
placeholder="Enter Zerodha API key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zerodha-api-secret">API secret</Label>
|
||||
<Input
|
||||
id="zerodha-api-secret"
|
||||
placeholder="Enter Zerodha API secret"
|
||||
type="password"
|
||||
value={apiSecret}
|
||||
onChange={(e) => setApiSecret(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => loginUrlMutation.mutate()}
|
||||
disabled={loginUrlMutation.isPending}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
{loginUrlMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
|
||||
</Button>
|
||||
{canReconnectWithSavedZerodha ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => reconnectSavedMutation.mutate()}
|
||||
disabled={reconnectSavedMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{reconnectSavedMutation.isPending ? "Opening Zerodha..." : "Reconnect saved Zerodha"}
|
||||
</Button>
|
||||
) : null}
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => holdingsMutation.mutate()}
|
||||
disabled={holdingsMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{holdingsMutation.isPending ? "Fetching..." : "Fetch holdings"}
|
||||
</Button>
|
||||
) : null}
|
||||
{connected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => disconnectMutation.mutate()}
|
||||
disabled={disconnectMutation.isPending}
|
||||
>
|
||||
{disconnectMutation.isPending ? "Disconnecting..." : "Disconnect broker"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After you log in with your Zerodha account, we will connect automatically. Keep this tab open
|
||||
until the login completes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key
|
||||
and secret without re-entering them.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Other brokers</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Groww (coming soon)</Badge>
|
||||
<Badge variant="outline">Angel One (coming soon)</Badge>
|
||||
<Badge variant="outline">ICICI Direct (coming soon)</Badge>
|
||||
<Badge variant="outline">HDFC Securities (coming soon)</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected && (
|
||||
<div className="space-y-2 rounded-lg border border-border/60 bg-background/80 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-semibold">Latest holdings</p>
|
||||
</div>
|
||||
{holdings.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No holdings pulled yet. Click “Fetch holdings” after connecting.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{holdings.map((item, idx) => (
|
||||
<div
|
||||
key={`${item.tradingsymbol || item.instrument_token || idx}`}
|
||||
className="flex items-center justify-between rounded-md border border-border/50 bg-card/40 px-3 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{item.tradingsymbol || item.symbol || "Instrument"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Qty: {item.quantity ?? item.qty ?? "-"} | Avg:{" "}
|
||||
{item.average_price ?? item.avg_price ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{item.exchange || item.exchange_type || "N/A"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-gradient-to-r from-primary/10 via-background to-background px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-primary/15 p-2 text-primary">
|
||||
<PlugZap className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm leading-tight">
|
||||
<p className="font-medium">Secure brokerage linking</p>
|
||||
<p className="text-muted-foreground">
|
||||
Connect with Zerodha through the broker login flow or use Groww with direct API approval.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={connected ? "secondary" : "outline"} className="ml-auto">
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
</Badge>
|
||||
</div>
|
||||
{connected ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<span>
|
||||
{brokerStatus?.userName
|
||||
? `Linked as ${brokerStatus.userName}`
|
||||
: `Linked to ${brokerLabel}`}{" "}
|
||||
via {brokerLabel}
|
||||
{" - "}
|
||||
{connectedAt ? connectedAt.toLocaleString() : "just now"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<motion.div
|
||||
className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 shadow-sm"
|
||||
whileHover={{
|
||||
y: -6,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 24 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Zerodha</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide your Kite API key and secret to launch the login and connect your account.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Live</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zerodha-api-key">API key</Label>
|
||||
<Input
|
||||
id="zerodha-api-key"
|
||||
placeholder="Enter Zerodha API key"
|
||||
value={zerodhaApiKey}
|
||||
onChange={(e) => setZerodhaApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zerodha-api-secret">API secret</Label>
|
||||
<Input
|
||||
id="zerodha-api-secret"
|
||||
placeholder="Enter Zerodha API secret"
|
||||
type="password"
|
||||
value={zerodhaApiSecret}
|
||||
onChange={(e) => setZerodhaApiSecret(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => zerodhaLoginMutation.mutate()}
|
||||
disabled={zerodhaLoginMutation.isPending}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
{zerodhaLoginMutation.isPending ? "Opening Zerodha..." : "Open Zerodha login"}
|
||||
</Button>
|
||||
{canReconnectWithSavedZerodha ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => zerodhaReconnectMutation.mutate()}
|
||||
disabled={zerodhaReconnectMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{zerodhaReconnectMutation.isPending ? "Opening Zerodha..." : "Reconnect saved Zerodha"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After you log in with your Zerodha account, we will connect automatically. Keep this tab open
|
||||
until the login completes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Zerodha access tokens expire daily. After the first setup, reconnect can use your saved API key
|
||||
and secret without re-entering them.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 shadow-sm"
|
||||
whileHover={{
|
||||
y: -6,
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 24 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Groww</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide your Groww API key and secret to generate a direct broker session for holdings and live execution.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">Live</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="groww-api-key">API key</Label>
|
||||
<Input
|
||||
id="groww-api-key"
|
||||
placeholder="Enter Groww API key"
|
||||
value={growwApiKey}
|
||||
onChange={(e) => setGrowwApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="groww-api-secret">API secret</Label>
|
||||
<Input
|
||||
id="groww-api-secret"
|
||||
placeholder="Enter Groww API secret"
|
||||
type="password"
|
||||
value={growwApiSecret}
|
||||
onChange={(e) => setGrowwApiSecret(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => growwConnectMutation.mutate()}
|
||||
disabled={growwConnectMutation.isPending}
|
||||
>
|
||||
<PlugZap className="h-4 w-4" />
|
||||
{growwConnectMutation.isPending ? "Connecting Groww..." : "Connect Groww"}
|
||||
</Button>
|
||||
{canReconnectWithSavedGroww ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => growwReconnectMutation.mutate()}
|
||||
disabled={growwReconnectMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{growwReconnectMutation.isPending ? "Refreshing Groww..." : "Reconnect saved Groww"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Groww reconnect runs server-side using your saved API key and secret. No browser callback is required after the first setup.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border/50 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">Other brokers</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Angel One (coming soon)</Badge>
|
||||
<Badge variant="outline">ICICI Direct (coming soon)</Badge>
|
||||
<Badge variant="outline">HDFC Securities (coming soon)</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connected ? (
|
||||
<div className="space-y-2 rounded-lg border border-border/60 bg-background/80 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-semibold">Latest holdings</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => holdingsMutation.mutate()}
|
||||
disabled={holdingsMutation.isPending}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
{holdingsMutation.isPending ? "Fetching..." : "Fetch holdings"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => disconnectMutation.mutate()}
|
||||
disabled={disconnectMutation.isPending}
|
||||
>
|
||||
{disconnectMutation.isPending ? "Disconnecting..." : "Disconnect broker"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{holdings.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No holdings pulled yet. Click “Fetch holdings” after connecting.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{holdings.map((item, idx) => (
|
||||
<div
|
||||
key={`${item.tradingsymbol || item.instrument_token || item.symbol || idx}`}
|
||||
className="flex items-center justify-between rounded-md border border-border/50 bg-card/40 px-3 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{item.tradingsymbol || item.symbol || "Instrument"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Qty: {item.quantity ?? item.qty ?? "-"} | Avg:{" "}
|
||||
{item.average_price ?? item.avg_price ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{item.exchange || item.exchange_type || "N/A"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -35,7 +35,7 @@ const faqItems = [
|
||||
{
|
||||
question: "What brokers are supported?",
|
||||
answer:
|
||||
"Zerodha is supported now. Additional brokers will be available soon.",
|
||||
"Zerodha and Groww are supported now. Additional brokers will be available soon.",
|
||||
},
|
||||
{
|
||||
question: "Is my data secure?",
|
||||
|
||||
@ -154,6 +154,13 @@ function buildReconnectRedirectUrl() {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function formatBrokerName(broker?: string | null) {
|
||||
const normalized = (broker || "").trim().toUpperCase();
|
||||
if (normalized === "GROWW") return "Groww";
|
||||
if (normalized === "ZERODHA") return "Zerodha";
|
||||
return normalized || "broker";
|
||||
}
|
||||
|
||||
function firstNumber(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const parsed = Number(value);
|
||||
@ -263,7 +270,7 @@ export default function PortfolioSection() {
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
async function startSavedBrokerReconnect() {
|
||||
async function startSavedZerodhaReconnect() {
|
||||
try {
|
||||
const redirectUrl = buildReconnectRedirectUrl();
|
||||
const params = new URLSearchParams({ redirectUrl });
|
||||
@ -295,6 +302,36 @@ export default function PortfolioSection() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startSavedGrowwReconnect() {
|
||||
try {
|
||||
const res = await apiRequest("POST", "/broker/groww/reconnect");
|
||||
await res.json();
|
||||
await Promise.allSettled([
|
||||
refetchBrokerStatus(),
|
||||
refreshBrokerData({ includeEquityCurve: true }),
|
||||
]);
|
||||
toast({
|
||||
title: "Groww reconnected",
|
||||
description: "Your Groww broker session has been refreshed.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message || "");
|
||||
if (message.includes("400:") && message.includes("Broker credentials not configured")) {
|
||||
setBrokerDialogOpen(true);
|
||||
toast({
|
||||
title: "Enter Groww API credentials",
|
||||
description: "Saved Groww credentials are missing. Enter the API key and secret once to reconnect.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.includes("401:")) {
|
||||
setLoginPromptOpen(true);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const disconnectBrokerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiRequest("POST", "/broker/disconnect");
|
||||
@ -306,9 +343,9 @@ export default function PortfolioSection() {
|
||||
setCachedHoldings([]);
|
||||
setCachedFunds(null);
|
||||
setCachedEquityCurve(null);
|
||||
queryClient.removeQueries({ queryKey: ["/zerodha/holdings"] });
|
||||
queryClient.removeQueries({ queryKey: ["/zerodha/funds"] });
|
||||
queryClient.removeQueries({ queryKey: ["/zerodha/equity-curve"] });
|
||||
queryClient.removeQueries({ queryKey: ["/broker/holdings"] });
|
||||
queryClient.removeQueries({ queryKey: ["/broker/funds"] });
|
||||
queryClient.removeQueries({ queryKey: ["/broker/equity-curve"] });
|
||||
await refetchBrokerStatus();
|
||||
toast({
|
||||
title: "Broker disconnected",
|
||||
@ -323,9 +360,9 @@ export default function PortfolioSection() {
|
||||
});
|
||||
|
||||
const holdingsQuery = useQuery<HoldingsResponse>({
|
||||
queryKey: ["/zerodha/holdings"],
|
||||
queryKey: ["/broker/holdings"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/zerodha/holdings");
|
||||
const res = await apiRequest("GET", "/broker/holdings");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!brokerStatus?.connected,
|
||||
@ -344,9 +381,9 @@ export default function PortfolioSection() {
|
||||
});
|
||||
|
||||
const fundsQuery = useQuery<FundsResponse>({
|
||||
queryKey: ["/zerodha/funds"],
|
||||
queryKey: ["/broker/funds"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/zerodha/funds");
|
||||
const res = await apiRequest("GET", "/broker/funds");
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!brokerStatus?.connected,
|
||||
@ -491,11 +528,11 @@ export default function PortfolioSection() {
|
||||
}, [prefersReducedMotion]);
|
||||
|
||||
const equityCurveQuery = useQuery<EquityCurveResponse>({
|
||||
queryKey: ["/zerodha/equity-curve", startDate],
|
||||
queryKey: ["/broker/equity-curve", startDate],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/zerodha/equity-curve${startDate ? `?from=${startDate}` : ""}`,
|
||||
`/broker/equity-curve${startDate ? `?from=${startDate}` : ""}`,
|
||||
);
|
||||
return res.json();
|
||||
},
|
||||
@ -619,6 +656,8 @@ export default function PortfolioSection() {
|
||||
const showSessionExpired = sessionExpired && isConnected;
|
||||
const brokerAuthExpired = (brokerStatus?.authState || "").toUpperCase() === "EXPIRED";
|
||||
const showReconnectBroker = isConnected && (showSessionExpired || brokerAuthExpired);
|
||||
const connectedBroker = (brokerStatus?.broker || "").trim().toUpperCase();
|
||||
const brokerLabel = formatBrokerName(connectedBroker);
|
||||
|
||||
const normalizedStrategyStatus =
|
||||
strategyStatus === "RUNNING"
|
||||
@ -727,17 +766,27 @@ export default function PortfolioSection() {
|
||||
return true;
|
||||
}, [refetchSessionUser]);
|
||||
|
||||
const reconnectCurrentBroker = async () => {
|
||||
if (connectedBroker === "GROWW") {
|
||||
return startSavedGrowwReconnect();
|
||||
}
|
||||
if (connectedBroker === "ZERODHA") {
|
||||
return startSavedZerodhaReconnect();
|
||||
}
|
||||
setBrokerDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleReconnectClick = useCallback(() => {
|
||||
requireLogin()
|
||||
.then((isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
if (brokerStatus?.connected && (brokerStatus?.broker || "").trim().toUpperCase() === "ZERODHA") {
|
||||
return startSavedBrokerReconnect().catch((error: any) =>
|
||||
if (brokerStatus?.connected) {
|
||||
return reconnectCurrentBroker().catch((error: any) =>
|
||||
toast({
|
||||
title: "Reconnect failed",
|
||||
description: error?.message || "Unable to start Zerodha reconnect.",
|
||||
description: error?.message || `Unable to reconnect ${brokerLabel}.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -746,7 +795,7 @@ export default function PortfolioSection() {
|
||||
.catch(() => {
|
||||
setLoginPromptOpen(true);
|
||||
});
|
||||
}, [brokerStatus, requireLogin]);
|
||||
}, [brokerLabel, brokerStatus, reconnectCurrentBroker, requireLogin]);
|
||||
|
||||
const handleRefreshBrokerData = useCallback(() => {
|
||||
void refreshBrokerData({ includeEquityCurve: true });
|
||||
@ -767,7 +816,7 @@ export default function PortfolioSection() {
|
||||
if (showSessionExpired || brokerAuthExpired) {
|
||||
toast({
|
||||
title: "Reconnect broker",
|
||||
description: "Your Zerodha session has expired. Reconnect before starting the live strategy.",
|
||||
description: `Your ${brokerLabel} session has expired. Reconnect before starting the live strategy.`,
|
||||
});
|
||||
handleReconnectClick();
|
||||
return;
|
||||
@ -786,9 +835,9 @@ export default function PortfolioSection() {
|
||||
if (result?.status === "broker_auth_required") {
|
||||
toast({
|
||||
title: "Reconnect broker",
|
||||
description: "Your Zerodha session has expired. Reconnect and try again.",
|
||||
description: `Your ${brokerLabel} session has expired. Reconnect and try again.`,
|
||||
});
|
||||
await startSavedBrokerReconnect();
|
||||
await reconnectCurrentBroker();
|
||||
return;
|
||||
}
|
||||
if (result?.status === "already_running") {
|
||||
@ -800,7 +849,7 @@ export default function PortfolioSection() {
|
||||
setFreshStartRequested(false);
|
||||
toast({
|
||||
title: "Live strategy started",
|
||||
description: "Golden Nifty is now armed for live Zerodha execution.",
|
||||
description: `Golden Nifty is now armed for live ${brokerLabel} execution.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
@ -839,7 +888,7 @@ export default function PortfolioSection() {
|
||||
if (showSessionExpired || brokerAuthExpired) {
|
||||
toast({
|
||||
title: "Reconnect broker",
|
||||
description: "Your Zerodha session has expired. Reconnect before resuming the live strategy.",
|
||||
description: `Your ${brokerLabel} session has expired. Reconnect before resuming the live strategy.`,
|
||||
});
|
||||
handleReconnectClick();
|
||||
return;
|
||||
@ -850,9 +899,9 @@ export default function PortfolioSection() {
|
||||
if (result?.status === "broker_auth_required") {
|
||||
toast({
|
||||
title: "Reconnect broker",
|
||||
description: "Your Zerodha session has expired. Reconnect and resume again.",
|
||||
description: `Your ${brokerLabel} session has expired. Reconnect and resume again.`,
|
||||
});
|
||||
await startSavedBrokerReconnect();
|
||||
await reconnectCurrentBroker();
|
||||
return;
|
||||
}
|
||||
if (result?.status === "already_running") {
|
||||
|
||||
@ -20,7 +20,7 @@ const privacyCards = [
|
||||
},
|
||||
{
|
||||
title: "Third Parties",
|
||||
body: "Brokerage APIs (e.g., Zerodha) are accessed only with your consent. No unauthorized sharing with any other party.",
|
||||
body: "Brokerage APIs (e.g., Zerodha and Groww) are accessed only with your consent. No unauthorized sharing with any other party.",
|
||||
},
|
||||
{
|
||||
title: "User Control",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user