feat(subscription): RFID enrollment, any-credential exit, prepaid booth handling
Rounds out subscriptions across enrollment, the barrier flow, and the booth.
- RFID credentials enabled with a "Read card" enrollment flow: the operator
arms ONE chosen reader (CredentialCapture, single-shot + ~30s TTL); that
reader's next read is captured into the form and NOT dispatched to the access
flow — the OTHER reader keeps serving live entry/exit. Routes:
/api/subscriptions/readers + /capture/{arm,cancel} + poll.
- Enter with one credential, exit with another: sessions are keyed by a
per-occurrence id (SUBSESS-<short>), not the credential value, with
permitId in the payload. Direction is decided by the barrier the reader sits
at (entry-lane→entry, exit-lane→exit; "both" infers); a fleet (maxConcurrent>1)
admits several cars and exits any with any credential, FIFO (oldest first).
- Booth treats a subscription occurrence as PREPAID: never quoted/charged; the
pay/exit modal shows a subscription mode (snapshots + a single audited
Open-barrier action) to assist a faulty exit reader / missing card;
reopenBarrier authorizes paidAt!=null OR subscription. Active Sessions badges
"abonim" and labels by holder name (not the raw key).
- Plus a per-read diagnostic log in the QR-reader route (serial → device →
verdict/dir), which surfaced the earlier duplicate-reader-IP misroute.
Verified via buildServer+inject + reader-scan/TCP-capture simulations
(enrollment isolation, cross-credential + FIFO fleet, prepaid-not-charged,
subscription reopen, unpaid-transient guard). Updated wiki (subscription,
booth-exit-flow). No migration.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
armCapture,
|
||||
cancelCapture,
|
||||
createSubscription,
|
||||
deleteSubscription,
|
||||
fetchReaders,
|
||||
fetchSiteConfig,
|
||||
fetchSubscriptions,
|
||||
pollCapture,
|
||||
printSubscription,
|
||||
revokeSubscription,
|
||||
updateSubscription,
|
||||
type ReaderInfo,
|
||||
type Subscription,
|
||||
type SubscriptionCredential,
|
||||
type SubscriptionInput,
|
||||
@@ -123,6 +128,11 @@ export function SubscriptionManager() {
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
// Credential capture ("Read card"): which credential index is being captured, the
|
||||
// reader picker list, and a live status line. null = no capture in progress.
|
||||
const [capture, setCapture] = useState<{ credIndex: number; phase: "pick" | "waiting"; status?: string } | null>(null);
|
||||
const [readers, setReaders] = useState<ReaderInfo[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
function reload() {
|
||||
fetchSubscriptions()
|
||||
@@ -203,6 +213,62 @@ export function SubscriptionManager() {
|
||||
setForm((f) => ({ ...f, credentials: f.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||
}
|
||||
|
||||
function clearPoll() {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
// Stop a capture in progress (cancel on the server + clear local state).
|
||||
function stopCapture() {
|
||||
clearPoll();
|
||||
void cancelCapture().catch(() => {});
|
||||
setCapture(null);
|
||||
}
|
||||
// "Read card" on credential i → load readers + show the picker.
|
||||
async function startCapture(i: number) {
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await fetchReaders();
|
||||
setReaders(r.readers);
|
||||
setCapture({ credIndex: i, phase: "pick" });
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
}
|
||||
}
|
||||
// Operator picked a reader → arm it and poll until captured / expired.
|
||||
async function pickReader(deviceId: string) {
|
||||
const cap = capture;
|
||||
if (!cap) return;
|
||||
try {
|
||||
await armCapture(deviceId);
|
||||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureWaiting") });
|
||||
clearPoll();
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const st = await pollCapture();
|
||||
if (st.status === "captured") {
|
||||
clearPoll();
|
||||
setCred(cap.credIndex, { value: st.value });
|
||||
void cancelCapture().catch(() => {}); // clear the server-side result
|
||||
setCapture(null);
|
||||
setMsg({ kind: "ok", text: t("subs.captured", { value: st.value }) });
|
||||
} else if (st.status === "expired" || st.status === "idle") {
|
||||
clearPoll();
|
||||
setCapture({ ...cap, phase: "waiting", status: t("subs.captureTimeout") });
|
||||
}
|
||||
} catch {
|
||||
/* transient poll error — keep polling */
|
||||
}
|
||||
}, 700);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "err", text: (e as Error).message });
|
||||
setCapture(null);
|
||||
}
|
||||
}
|
||||
// Stop polling if the form closes or the component unmounts.
|
||||
useEffect(() => clearPoll, []);
|
||||
|
||||
// Live coverage preview: when months + validFrom are set, show the end date and
|
||||
// (if priced) the N×monthly total the operator should collect.
|
||||
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
|
||||
@@ -299,12 +365,11 @@ export function SubscriptionManager() {
|
||||
<h4 style={{ marginBottom: "0.25rem" }}>{t("subs.credentials")}</h4>
|
||||
{form.credentials.map((c, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: "0.4rem", marginBottom: "0.3rem" }}>
|
||||
{/* Operator chooses the credential type. Only QR is live today; RFID
|
||||
is shown disabled ("soon") so the choice is visible — the backend
|
||||
already accepts both, so re-enabling RFID is just dropping `disabled`. */}
|
||||
{/* Operator chooses the credential type: QR (auto-generated) or RFID
|
||||
(read off a card via "Read card"). */}
|
||||
<select value={c.kind} onChange={(e) => setCred(i, { kind: e.target.value as "rf" | "qr" })}>
|
||||
<option value="qr">{t("subs.qr")}</option>
|
||||
<option value="rf" disabled>{t("subs.rfCardTagSoon")}</option>
|
||||
<option value="rf">{t("subs.rfCardTag")}</option>
|
||||
</select>
|
||||
{c.kind === "qr" ? (
|
||||
// QR codes are server-generated. Blank → "will be generated"; an
|
||||
@@ -315,12 +380,44 @@ export function SubscriptionManager() {
|
||||
<span style={{ flex: 1, color: "#888", fontStyle: "italic", alignSelf: "center" }}>{t("subs.qrAutoGen")}</span>
|
||||
)
|
||||
) : (
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.credentialValue")} style={{ flex: 1 }} />
|
||||
// RFID: the value is read off a physical card (or typed). "Read card"
|
||||
// arms a chosen reader and fills the captured value.
|
||||
<input value={c.value} onChange={(e) => setCred(i, { value: e.target.value })} placeholder={t("subs.rfPlaceholder")} style={{ flex: 1, fontFamily: "ui-monospace, monospace" }} />
|
||||
)}
|
||||
{c.kind === "rf" && (
|
||||
<button type="button" onClick={() => startCapture(i)} disabled={capture != null}>{t("subs.readCard")}</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: f.credentials.filter((_, j) => j !== i) }))}>×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setForm((f) => ({ ...f, credentials: [...f.credentials, { kind: "qr", value: "" }] }))}>{t("subs.addCredential")}</button>
|
||||
|
||||
{/* Capture panel: pick a reader, present the card; the captured value fills
|
||||
the credential. The OTHER reader keeps serving the live flow. */}
|
||||
{capture && (
|
||||
<div style={{ marginTop: "0.5rem", padding: "0.6rem 0.75rem", border: "1px solid #0a7", borderRadius: 6, background: "#f0fbf6" }}>
|
||||
{capture.phase === "pick" ? (
|
||||
<>
|
||||
<div style={{ marginBottom: "0.35rem" }}>{t("subs.captureChooseReader")}</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
|
||||
{readers.length === 0 && <span style={{ color: "#a00" }}>{t("subs.captureNoReaders")}</span>}
|
||||
{readers.map((r) => (
|
||||
<button key={r.id} type="button" onClick={() => pickReader(r.id)}>
|
||||
{t(`devices.role.${r.direction}`)} ({r.driverId})
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.6rem", alignItems: "center" }}>
|
||||
<span>{capture.status ?? t("subs.captureWaiting")}</span>
|
||||
<button type="button" onClick={stopCapture}>{t("subs.cancel")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: "#777", fontSize: "0.85em", margin: "0.5rem 0 0" }}>
|
||||
{t("subs.needCredentialOrPlate")}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user