fix: Make GST captcha a popup #115
2 changed files with 182 additions and 27 deletions
113
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
113
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
|
@ -113,6 +113,44 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA popup — overlays the vendor form (which is itself an AdminDialog at z-50) so the
|
||||||
|
// CAPTCHA never grows the form and pushes its footer buttons off-screen. Sits at z-[60] and
|
||||||
|
// handles Escape on the capture phase so closing it does NOT also close the underlying form.
|
||||||
|
function CaptchaPopup({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") { e.stopImmediatePropagation(); onClose(); }
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey, true);
|
||||||
|
return () => document.removeEventListener("keydown", onKey, true);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">GSTIN CAPTCHA</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 transition-colors text-lg leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
|
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
|
||||||
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
||||||
const [name, setName] = useState(vendor?.name ?? "");
|
const [name, setName] = useState(vendor?.name ?? "");
|
||||||
|
|
@ -149,13 +187,19 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
||||||
});
|
});
|
||||||
const data: GstResult & { error?: string } = await res.json();
|
const data: GstResult & { error?: string } = await res.json();
|
||||||
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
|
// Keep the popup open on error so the user sees it in context and can retry / get a new image.
|
||||||
|
if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
|
||||||
setName(data.tradeName || data.legalName);
|
setName(data.tradeName || data.legalName);
|
||||||
setAddress(data.address);
|
setAddress(data.address);
|
||||||
if (data.pincode) setPincode(data.pincode);
|
if (data.pincode) setPincode(data.pincode);
|
||||||
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
||||||
setCaptchaStep("idle");
|
setCaptchaStep("idle");
|
||||||
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
|
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the CAPTCHA popup without touching the vendor form fields.
|
||||||
|
function closeCaptcha() {
|
||||||
|
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -183,31 +227,46 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{captchaStep === "ready" && captchaB64 && (
|
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
|
||||||
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
{captchaStep === "loading" ? (
|
||||||
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
|
<p className="py-4 text-center text-sm text-neutral-500">Loading CAPTCHA…</p>
|
||||||
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
) : (
|
||||||
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
<div className="space-y-3">
|
||||||
<div className="flex gap-2 items-center">
|
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
|
||||||
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
{captchaB64 && (
|
||||||
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
||||||
placeholder="6 digits"
|
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
||||||
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
|
)}
|
||||||
autoFocus
|
<div className="flex gap-2 items-center">
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
||||||
/>
|
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
||||||
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6}
|
placeholder="6 digits"
|
||||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
disabled={captchaStep === "verifying"}
|
||||||
Verify
|
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none disabled:opacity-60"
|
||||||
</button>
|
autoFocus
|
||||||
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
||||||
New image
|
/>
|
||||||
</button>
|
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6 || captchaStep === "verifying"}
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
||||||
|
{captchaStep === "verifying" ? "Verifying…" : "Verify"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
|
||||||
|
className="text-xs text-neutral-500 hover:underline disabled:opacity-50">
|
||||||
|
New image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{gstError && <p className="text-xs text-danger-600">{gstError}</p>}
|
||||||
|
<div className="flex justify-end border-t border-neutral-100 pt-3">
|
||||||
|
<button type="button" onClick={closeCaptcha}
|
||||||
|
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</CaptchaPopup>
|
||||||
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying…</p>}
|
{/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
|
||||||
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
{captchaStep === "idle" && gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
||||||
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
96
App/tests/unit/vendor-form-captcha.test.tsx
Normal file
96
App/tests/unit/vendor-form-captcha.test.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { AddVendorButton } from "@/app/(portal)/admin/vendors/vendor-form";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The form imports server actions; stub them so the client component renders in jsdom.
|
||||||
|
vi.mock("@/app/(portal)/admin/vendors/actions", () => ({
|
||||||
|
createVendor: vi.fn(),
|
||||||
|
updateVendor: vi.fn(),
|
||||||
|
toggleVendorActive: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const GSTIN = "27AAHCP5787B1Z6"; // 15 chars
|
||||||
|
|
||||||
|
describe("VendorForm — GSTIN CAPTCHA popup (issue #114)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn(async () =>
|
||||||
|
new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openFormAndLookup() {
|
||||||
|
render(<AddVendorButton simple />);
|
||||||
|
fireEvent.click(screen.getByText("+ Add Vendor"));
|
||||||
|
const gstinInput = screen.getByPlaceholderText(/27AAHCP5787B1Z6/);
|
||||||
|
fireEvent.change(gstinInput, { target: { value: GSTIN } });
|
||||||
|
fireEvent.click(screen.getByText("Look up"));
|
||||||
|
// Popup renders the CAPTCHA prompt once the fetch resolves.
|
||||||
|
await screen.findByText(/Enter the code shown in the image/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("opens the CAPTCHA in a popup with a Cancel/Close control, leaving the form footer reachable", async () => {
|
||||||
|
await openFormAndLookup();
|
||||||
|
// The CAPTCHA lives in its own popup …
|
||||||
|
expect(screen.getByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeTruthy();
|
||||||
|
// Both the form's ✕ and the popup's ✕/Cancel close controls are present.
|
||||||
|
expect(screen.getAllByLabelText("Close").length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getAllByText("Cancel").length).toBeGreaterThanOrEqual(2);
|
||||||
|
// … and the underlying vendor form's submit button is still rendered (never displaced).
|
||||||
|
expect(screen.getByText("Create Vendor")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popup on Cancel without closing the vendor form", async () => {
|
||||||
|
await openFormAndLookup();
|
||||||
|
// The popup's Cancel is the first one in the DOM (the CAPTCHA section precedes the footer).
|
||||||
|
fireEvent.click(screen.getAllByText("Cancel")[0]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
|
||||||
|
});
|
||||||
|
// The vendor form itself stays open.
|
||||||
|
expect(screen.getByText("Create Vendor")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("heading", { name: /GSTIN CAPTCHA/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies the CAPTCHA, fills the form fields, and closes the popup on success", async () => {
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
|
||||||
|
if (String(url).includes("/api/gst/captcha")) {
|
||||||
|
return new Response(JSON.stringify({ captchaBase64: "ABC123", sessionId: "sess-1" }), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
legalName: "Acme Pvt Ltd",
|
||||||
|
tradeName: "Acme",
|
||||||
|
address: "1 Dock Rd",
|
||||||
|
pincode: "400001",
|
||||||
|
gstin: GSTIN,
|
||||||
|
status: "Active",
|
||||||
|
registrationDate: "2020-01-01",
|
||||||
|
}),
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await openFormAndLookup();
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("6 digits"), { target: { value: "123456" } });
|
||||||
|
fireEvent.click(screen.getByText("Verify"));
|
||||||
|
|
||||||
|
// Popup closes; success line + populated fields appear on the form.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/Enter the code shown in the image/i)).toBeNull();
|
||||||
|
});
|
||||||
|
expect((screen.getByDisplayValue("Acme") as HTMLInputElement)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Acme Pvt Ltd — Active/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue