fix(cf-modal): inline auto-connect + credential form — no parcel-sync hop

Marius: don't kick the user out to /parcel-sync just to connect ePay,
do everything inside the modal. The parcel-sync page also wasn't
helpful when reached (UAT selector empty), so the redirect was a
dead-end anyway.

State machine rewrite:

  loading-status → GET /api/ancpi/session
  not-connected  → DELETED (replaced by transparent flow)
  connecting     → POST /api/ancpi/session with {} — server picks up
                   ANCPI_USERNAME/ANCPI_PASSWORD from env (Infisical
                   has them in /architools), connects silently
  need-credentials → only if env creds are missing OR invalid: shows
                   an inline form (username / password / Conectează
                   button + privacy note "nu sunt păstrate la noi
                   după sfârșitul sesiunii")
  no-credits / ready / placing / processing / done / error — as before

Flow for the happy path (Marius's case): user clicks "Comandă CF" →
modal shows "Conectare la ePay ANCPI…" for ~1s → "Verificare credite"
done → "Ești sigur? 1 credit, mai ai X" → confirm → animated steps
→ done. Zero page navigations.

Flow for the no-env case (other tenants or first-run): user sees
inline form, types credentials, presses Conectează → server stores
them in the in-memory session for the lifetime of the request,
modal continues straight to "ready".

Removed:
- goToParcelSync() handler + "Conectează ePay" deep-link button
- "not-connected" UI panel
- Phase value "not-connected" (no longer reachable)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-20 08:10:33 +03:00
parent 5e4618b309
commit ad89da690f
+124 -41
View File
@@ -25,7 +25,8 @@ import { cn } from "@/shared/lib/utils";
type Phase = type Phase =
| "loading-status" | "loading-status"
| "not-connected" | "connecting"
| "need-credentials"
| "no-credits" | "no-credits"
| "ready" | "ready"
| "placing" | "placing"
@@ -79,6 +80,8 @@ export function CfOrderModal({
const [orderId, setOrderId] = useState<string | null>(null); const [orderId, setOrderId] = useState<string | null>(null);
const [documentUrl, setDocumentUrl] = useState<string | null>(null); const [documentUrl, setDocumentUrl] = useState<string | null>(null);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const startedAtRef = useRef<number>(0); const startedAtRef = useRef<number>(0);
const pollAbortRef = useRef<AbortController | null>(null); const pollAbortRef = useRef<AbortController | null>(null);
@@ -110,25 +113,69 @@ export function CfOrderModal({
return () => clearInterval(id); return () => clearInterval(id);
}, [phase]); }, [phase]);
const evaluateStatus = useCallback((data: SessionStatus) => {
setStatus(data);
if (!data.connected) return "need-connect" as const;
if (data.credits != null && data.credits < 1) {
setPhase("no-credits");
return "no-credits" as const;
}
setPhase("ready");
return "ready" as const;
}, []);
const tryAutoConnect = useCallback(
async (creds?: { username: string; password: string }) => {
setPhase("connecting");
setError(null);
try {
const res = await fetch("/api/ancpi/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(creds ?? {}),
});
const data = (await res.json()) as {
success?: boolean;
credits?: number | null;
error?: string;
};
if (!res.ok || data.error) {
// 400 with empty body = no server-side env creds. Show the
// inline credential form. 401 = bad credentials.
if (creds) {
setError(data.error ?? "Credențiale ePay invalide");
}
setPhase("need-credentials");
return;
}
// Re-fetch status to confirm
const sRes = await fetch("/api/ancpi/session", { cache: "no-store" });
const sData = (await sRes.json()) as SessionStatus;
evaluateStatus(sData);
} catch {
setPhase("need-credentials");
setError("Nu s-a putut conecta automat. Introdu credențialele manual.");
}
},
[evaluateStatus],
);
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
try { try {
const res = await fetch("/api/ancpi/session", { cache: "no-store" }); const res = await fetch("/api/ancpi/session", { cache: "no-store" });
const data = (await res.json()) as SessionStatus; const data = (await res.json()) as SessionStatus;
setStatus(data); const result = evaluateStatus(data);
if (!data.connected) { if (result === "need-connect") {
setPhase("not-connected"); // Session not active yet — silently try the server-side env
return; // credentials (ANCPI_USERNAME / ANCPI_PASSWORD). Most installs
// have these in Infisical so connection happens transparently.
await tryAutoConnect();
} }
if (data.credits != null && data.credits < 1) {
setPhase("no-credits");
return;
}
setPhase("ready");
} catch { } catch {
setError("Nu pot verifica sesiunea ePay."); setError("Nu pot verifica sesiunea ePay.");
setPhase("error"); setPhase("error");
} }
}, []); }, [evaluateStatus, tryAutoConnect]);
const placeOrder = useCallback(async () => { const placeOrder = useCallback(async () => {
setPhase("placing"); setPhase("placing");
@@ -228,11 +275,6 @@ export function CfOrderModal({
[cadastralRef], [cadastralRef],
); );
const goToParcelSync = useCallback(() => {
const url = `/parcel-sync?tab=epay&cad=${encodeURIComponent(cadastralRef)}`;
window.open(url, "_blank", "noopener,noreferrer");
}, [cadastralRef]);
if (!open) return null; if (!open) return null;
return ( return (
@@ -284,29 +326,76 @@ export function CfOrderModal({
</div> </div>
)} )}
{/* Not connected */} {/* Connecting to ePay (auto, env creds) */}
{phase === "not-connected" && ( {phase === "connecting" && (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Conectare la ePay ANCPI</span>
</div>
)}
{/* Need credentials — server didn't have them or auto-connect failed */}
{phase === "need-credentials" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-3 text-sm dark:bg-amber-950/30"> <div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-3 text-xs dark:bg-amber-950/30">
<Plug className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" /> <Plug className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="space-y-1"> <div className="space-y-0.5">
<p className="font-medium text-amber-900 dark:text-amber-200"> <p className="font-medium text-amber-900 dark:text-amber-200">
ePay neconectat Conectează-te la ePay ANCPI
</p> </p>
<p className="text-xs text-amber-800/80 dark:text-amber-200/80"> <p className="text-amber-800/80 dark:text-amber-200/80">
Conectează-te în secțiunea Parcel Sync ePay pentru {error ?? "Introdu credențialele tale ePay pentru a continua."}
a putea comanda extrase CF.
</p> </p>
</div> </div>
</div> </div>
<button <form
type="button" className="space-y-2"
onClick={goToParcelSync} onSubmit={(e) => {
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md border bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90" e.preventDefault();
if (!username || !password) return;
void tryAutoConnect({ username, password });
}}
> >
Conectează ePay <label className="block">
<ArrowRight className="h-3.5 w-3.5" /> <span className="text-[11px] uppercase tracking-wider text-muted-foreground">
</button> Utilizator
</span>
<input
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-2.5 py-1.5 text-sm outline-none focus:border-primary"
placeholder="email@exemplu.ro"
required
/>
</label>
<label className="block">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
Parolă
</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-2.5 py-1.5 text-sm outline-none focus:border-primary"
required
/>
</label>
<button
type="submit"
disabled={!username || !password}
className="inline-flex w-full items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
<Plug className="h-3.5 w-3.5" />
Conectează
</button>
<p className="pt-1 text-[10px] text-muted-foreground">
Credențialele sunt trimise direct la ANCPI; nu sunt
păstrate la noi după sfârșitul sesiunii.
</p>
</form>
</div> </div>
)} )}
@@ -462,7 +551,10 @@ export function CfOrderModal({
</button> </button>
</> </>
)} )}
{(phase === "loading-status" || phase === "no-credits") && ( {(phase === "loading-status" ||
phase === "connecting" ||
phase === "no-credits" ||
phase === "need-credentials") && (
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -471,15 +563,6 @@ export function CfOrderModal({
Închide Închide
</button> </button>
)} )}
{phase === "not-connected" && (
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium hover:bg-muted"
>
Renunță
</button>
)}
{(phase === "placing" || phase === "processing") && ( {(phase === "placing" || phase === "processing") && (
<button <button
type="button" type="button"