fix(canvas/ApprovalBanner): add double-submit guard + WCAG AA contrast fix #851

Closed
fullstack-engineer wants to merge 1 commits from fix/canvas-approvalbanner-a11y into staging
2 changed files with 186 additions and 40 deletions

View File

@ -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>
);
}

View File

@ -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!({});
});
});