Add Auto-Login setup card to portfolio page

- New AutoLoginSetup component: form to save Zerodha credentials and
  TOTP secret, shows last refresh time, error state, manual refresh
  and remove buttons
- New autoLogin.ts API client for all auto-login endpoints
- AutoLoginSetup card rendered in PortfolioSection when broker is connected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thigazhezhilan J 2026-05-02 12:47:29 +05:30
parent 46cf062fe2
commit 98894617e2
3 changed files with 270 additions and 0 deletions

33
src/api/autoLogin.ts Normal file
View File

@ -0,0 +1,33 @@
import { apiRequest } from "@/lib/queryClient";
export type AutoLoginStatus = {
configured: boolean;
last_refreshed_at: string | null;
last_error: string | null;
};
export type AutoLoginSetupRequest = {
zerodha_login_id: string;
password: string;
totp_secret: string;
};
export async function getAutoLoginStatus(): Promise<AutoLoginStatus> {
const res = await apiRequest("GET", "/auto-login/status");
return res.json();
}
export async function setupAutoLogin(data: AutoLoginSetupRequest): Promise<{ configured: boolean; message: string }> {
const res = await apiRequest("POST", "/auto-login/setup", data);
return res.json();
}
export async function removeAutoLogin(): Promise<{ configured: boolean; message: string }> {
const res = await apiRequest("DELETE", "/auto-login/setup");
return res.json();
}
export async function triggerAutoLogin(): Promise<{ success: boolean; message: string }> {
const res = await apiRequest("POST", "/auto-login/trigger");
return res.json();
}

View File

@ -0,0 +1,227 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ShieldCheck, ShieldOff, RefreshCw, Eye, EyeOff, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { toast } from "@/hooks/use-toast";
import {
getAutoLoginStatus,
setupAutoLogin,
removeAutoLogin,
triggerAutoLogin,
} from "@/api/autoLogin";
export default function AutoLoginSetup() {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showTotp, setShowTotp] = useState(false);
const [form, setForm] = useState({ zerodha_login_id: "", password: "", totp_secret: "" });
const { data: status, isLoading } = useQuery({
queryKey: ["/auto-login/status"],
queryFn: getAutoLoginStatus,
refetchInterval: 60_000,
});
const setupMutation = useMutation({
mutationFn: setupAutoLogin,
onSuccess: () => {
toast({ title: "Auto-login enabled", description: "Your Zerodha session will refresh automatically every morning." });
setShowForm(false);
setForm({ zerodha_login_id: "", password: "", totp_secret: "" });
queryClient.invalidateQueries({ queryKey: ["/auto-login/status"] });
},
onError: (err: any) => {
toast({ title: "Setup failed", description: err?.message ?? "Could not verify credentials. Check and try again.", variant: "destructive" });
},
});
const removeMutation = useMutation({
mutationFn: removeAutoLogin,
onSuccess: () => {
toast({ title: "Auto-login removed", description: "You will need to reconnect Zerodha manually each day." });
queryClient.invalidateQueries({ queryKey: ["/auto-login/status"] });
},
});
const triggerMutation = useMutation({
mutationFn: triggerAutoLogin,
onSuccess: () => {
toast({ title: "Session refreshed", description: "Zerodha token has been refreshed successfully." });
queryClient.invalidateQueries({ queryKey: ["/auto-login/status"] });
},
onError: (err: any) => {
toast({ title: "Refresh failed", description: err?.message ?? "Could not refresh session.", variant: "destructive" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.zerodha_login_id || !form.password || !form.totp_secret) {
toast({ title: "All fields are required", variant: "destructive" });
return;
}
setupMutation.mutate(form);
};
if (isLoading) return null;
const configured = status?.configured ?? false;
const lastRefreshed = status?.last_refreshed_at
? new Date(status.last_refreshed_at).toLocaleString("en-IN", { timeZone: "Asia/Kolkata", dateStyle: "medium", timeStyle: "short" })
: null;
const hasError = !!status?.last_error;
return (
<div className="rounded-xl border border-border bg-card p-5 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Auto-Login</span>
</div>
{configured ? (
<Badge variant="outline" className="text-xs border-green-500 text-green-500">
Active
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not configured
</Badge>
)}
</div>
{/* Status info */}
{configured && (
<div className="space-y-1 text-xs text-muted-foreground">
{lastRefreshed && <p>Last refreshed: {lastRefreshed} IST</p>}
{hasError && (
<p className="text-red-400">Last error: {status!.last_error}</p>
)}
<p className="text-muted-foreground/70">Token refreshes automatically at 6:05 AM IST daily.</p>
</div>
)}
{!configured && !showForm && (
<p className="text-xs text-muted-foreground">
Enable auto-login so QuantFortune can refresh your Zerodha session every morning your strategy runs uninterrupted.
</p>
)}
{/* Action buttons when configured */}
{configured && (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="text-xs flex-1"
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
>
<RefreshCw className={`h-3 w-3 mr-1 ${triggerMutation.isPending ? "animate-spin" : ""}`} />
Refresh Now
</Button>
<Button
size="sm"
variant="outline"
className="text-xs text-red-400 border-red-400/30 hover:bg-red-400/10"
onClick={() => removeMutation.mutate()}
disabled={removeMutation.isPending}
>
<Trash2 className="h-3 w-3 mr-1" />
Remove
</Button>
</div>
)}
{/* Setup button */}
{!configured && !showForm && (
<Button size="sm" className="w-full text-xs" onClick={() => setShowForm(true)}>
<ShieldCheck className="h-3 w-3 mr-1" />
Enable Auto-Login
</Button>
)}
{/* Setup form */}
{!configured && showForm && (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Zerodha Login ID</Label>
<Input
className="text-xs h-8"
placeholder="e.g. AB1234"
value={form.zerodha_login_id}
onChange={(e) => setForm((f) => ({ ...f, zerodha_login_id: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Zerodha Password</Label>
<div className="relative">
<Input
className="text-xs h-8 pr-8"
type={showPassword ? "text" : "password"}
placeholder="Your Zerodha login password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">TOTP Secret Key</Label>
<div className="relative">
<Input
className="text-xs h-8 pr-8"
type={showTotp ? "text" : "password"}
placeholder="From Zerodha 2FA setup (not the 6-digit OTP)"
value={form.totp_secret}
onChange={(e) => setForm((f) => ({ ...f, totp_secret: e.target.value }))}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowTotp((v) => !v)}
>
{showTotp ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
<p className="text-xs text-muted-foreground/70">
Find this in Zerodha My Profile Account Security 2FA View TOTP secret key.
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
size="sm"
className="flex-1 text-xs"
disabled={setupMutation.isPending}
>
{setupMutation.isPending ? "Verifying..." : "Save & Verify"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="text-xs"
onClick={() => { setShowForm(false); setForm({ zerodha_login_id: "", password: "", totp_secret: "" }); }}
>
Cancel
</Button>
</div>
</form>
)}
</div>
);
}

View File

@ -19,6 +19,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import StrategyTimeline from "@/components/StrategyTimeline"; import StrategyTimeline from "@/components/StrategyTimeline";
import AutoLoginSetup from "@/components/AutoLoginSetup";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import { import {
ChartContainer, ChartContainer,
@ -1667,6 +1668,15 @@ export default function PortfolioSection() {
</div> </div>
</div> </div>
{isConnected && (
<div
className={`${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "650ms" }}
>
<AutoLoginSetup />
</div>
)}
<div <div
className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`} className={`rounded-2xl border border-border/60 bg-card/70 shadow-xl p-6 space-y-4 ${hoverLift} ${revealTransition} ${cardRevealClass}`}
style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }} style={prefersReducedMotion ? undefined : { transitionDelay: "700ms" }}