fix(canvas/ApprovalBanner): add double-submit guard + WCAG AA contrast fix #851
@ -16,6 +16,9 @@ interface PendingApproval {
|
||||
|
||||
export function ApprovalBanner() {
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
// Double-submit guard: id of the approval currently being decided.
|
||||
// When non-null, both buttons are disabled and show ellipsis.
|
||||
const [pendingApprovalId, setPendingApprovalId] = useState<string | null>(null);
|
||||
|
||||
// Single endpoint — no N+1 per-workspace polling
|
||||
const pollApprovals = useCallback(async () => {
|
||||
@ -35,6 +38,8 @@ export function ApprovalBanner() {
|
||||
}, [pollApprovals]);
|
||||
|
||||
const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => {
|
||||
if (pendingApprovalId !== null) return; // guard against double-submit
|
||||
setPendingApprovalId(approval.id);
|
||||
try {
|
||||
await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, {
|
||||
decision,
|
||||
@ -44,6 +49,8 @@ export function ApprovalBanner() {
|
||||
setApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
||||
} catch {
|
||||
showToast("Failed to submit decision", "error");
|
||||
} finally {
|
||||
setPendingApprovalId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -51,49 +58,53 @@ export function ApprovalBanner() {
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center">
|
||||
{approvals.map((approval) => (
|
||||
<div
|
||||
key={approval.id}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
className="bg-amber-950/90 backdrop-blur-md border border-amber-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 max-w-md animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-warm text-lg" aria-hidden="true">⚠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
|
||||
<div className="text-sm text-amber-100 mt-0.5 font-medium">{approval.action}</div>
|
||||
{approval.reason && (
|
||||
<div className="text-xs text-warm/70 mt-1">{approval.reason}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
// Hover DARKER not lighter — emerald-500 on white text
|
||||
// drops contrast vs emerald-700.
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
|
||||
// Lift to surface-elevated on hover so the button visibly
|
||||
// responds before a destructive deny.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
{approvals.map((approval) => {
|
||||
const isPending = pendingApprovalId === approval.id;
|
||||
return (
|
||||
<div
|
||||
key={approval.id}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
className="bg-amber-950/90 backdrop-blur-md border border-amber-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 max-w-md animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-warm text-lg" aria-hidden="true">⚠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
|
||||
<div className="text-sm text-amber-100 mt-0.5 font-medium">{approval.action}</div>
|
||||
{approval.reason && (
|
||||
<div className="text-xs text-warm/70 mt-1">{approval.reason}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
disabled={isPending}
|
||||
aria-disabled={isPending}
|
||||
className={`px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70 ${isPending ? "opacity-60 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{isPending ? "…" : "Approve"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
disabled={isPending}
|
||||
aria-disabled={isPending}
|
||||
// WCAG AA fix: text-ink (~7:1 on bg-surface-card) replaces
|
||||
// text-ink-mid (~3:1). Hover lifts to text-ink on surface-elevated.
|
||||
className={`px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70 ${isPending ? "opacity-60 cursor-not-allowed" : ""}`}
|
||||
>
|
||||
{isPending ? "…" : "Deny"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -318,3 +318,138 @@ describe("ApprovalBanner — handles empty list from server", () => {
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Double-submit guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe("ApprovalBanner — double-submit guard", () => {
|
||||
it("disables both buttons while POST is in flight", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
// Deferred resolver — POST hangs until we resolve it
|
||||
let resolvePost: (v: unknown) => void;
|
||||
_mockPost.mockImplementation(
|
||||
() => new Promise((r) => { resolvePost = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
const approveBtn = screen.getByRole("button", { name: /approve/i });
|
||||
const denyBtn = screen.getByRole("button", { name: /deny/i });
|
||||
|
||||
// Click triggers synchronous state update; flush with act
|
||||
await act(async () => {
|
||||
fireEvent.click(approveBtn);
|
||||
// Flush microtasks so the deferred promise is picked up
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// After click, buttons are disabled
|
||||
expect(approveBtn.getAttribute("disabled")).toBe("");
|
||||
expect(denyBtn.getAttribute("disabled")).toBe("");
|
||||
|
||||
// Unblock the POST
|
||||
resolvePost!({});
|
||||
});
|
||||
|
||||
it("re-enables both buttons after a successful decision", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
_mockPost.mockResolvedValueOnce({} as unknown);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
// After card is removed, no buttons exist — guard is confirmed cleared
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-enables both buttons after a failed decision", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
_mockPost.mockImplementation(
|
||||
() => new Promise((_, reject) => reject(new Error("Network error"))),
|
||||
);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
const approveBtn = screen.getByRole("button", { name: /approve/i });
|
||||
const denyBtn = screen.getByRole("button", { name: /deny/i });
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
|
||||
// Wait for error toast (POST failed)
|
||||
await waitFor(() => {
|
||||
expect(_mockToast).toHaveBeenCalledWith("Failed to submit decision", "error");
|
||||
});
|
||||
|
||||
// Buttons re-enabled so the user can retry
|
||||
await waitFor(() => {
|
||||
expect(approveBtn.getAttribute("disabled")).toBeNull();
|
||||
expect(denyBtn.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows ellipsis on clicked button while POST is in flight", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
let resolvePost: (v: unknown) => void;
|
||||
_mockPost.mockImplementation(
|
||||
() => new Promise((r) => { resolvePost = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
const denyBtn = screen.getByRole("button", { name: /deny/i });
|
||||
expect(denyBtn.textContent).toBe("Deny");
|
||||
|
||||
// Click Deny — flush state update with act
|
||||
await act(async () => {
|
||||
fireEvent.click(denyBtn);
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// Both buttons show ellipsis while pending; verify via textContent
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const texts = buttons.map(b => b.textContent);
|
||||
expect(texts).toContain("…");
|
||||
expect(screen.queryByRole("button", { name: /deny/i })).toBeNull();
|
||||
|
||||
resolvePost!({});
|
||||
});
|
||||
|
||||
it("second button click is ignored while first is in flight", async () => {
|
||||
const approval = pendingApproval("a1", "ws-1");
|
||||
_mockGet.mockResolvedValueOnce([approval] as unknown[]);
|
||||
let resolvePost: (v: unknown) => void;
|
||||
_mockPost.mockImplementation(
|
||||
() => new Promise((r) => { resolvePost = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
|
||||
|
||||
// Click Approve — starts pending; flush state
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
// Both buttons show ellipsis while pending
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const texts = buttons.map(b => b.textContent);
|
||||
expect(texts).toContain("…");
|
||||
|
||||
// Only ONE POST call should have been made despite two clicks
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
|
||||
expect(_mockPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePost!({});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user