diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index 4bcdc31..a810897 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -5,6 +5,17 @@ import Inbox from "./Inbox"; import * as inboxApi from "@/lib/inbox-api"; import * as downloadModule from "@/lib/download"; +// The Inbox page now gates the Resubmit/Dismiss BulkBars behind +// . Tests need an "admin" +// principal so the bulk bar still renders — otherwise the +// SP8 resubmit-with-download assertion below has nothing to click. +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + user: { id: 1, username: "test-admin", role: "admin" }, + status: "authenticated", + }), +})); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 90c8f3f..72fab99 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -18,6 +18,7 @@ import { resubmitRejectedWithDownload, } from "@/lib/inbox-api"; import { downloadBlob } from "@/lib/download"; +import { RoleGate } from "@/auth/RoleGate"; type LaneKey = "rejected" | "candidates" | "unmatched" | "done_today"; @@ -202,21 +203,31 @@ export default function Inbox() { /> - {/* Per-lane bulk bars. Each shows when its lane has a selection. */} - {}} - onExport={() => onExport("rejected")} - /> - {}} - onDismiss={onDismiss} - onExport={() => onExport("candidates")} - /> + {/* Per-lane bulk bars. Each shows when its lane has a selection. + The two write-affordance BulkBars (rejected → resubmit, + candidates → dismiss) are gated by RoleGate so a viewer-role + account can still SEE the rows and select them but cannot + trigger a state change. The export buttons live inside the + same BulkBar component but are read-only, so we let them + remain visible to viewers. */} + + {}} + onExport={() => onExport("rejected")} + /> + + + {}} + onDismiss={onDismiss} + onExport={() => onExport("candidates")} + /> + ({ }, })); +// Match selected is now behind . +// The renderIntoContainer helper does not wrap in , +// so the hook would otherwise throw. Pretend we're an admin so the +// button still renders and these tests can exercise the two-column +// selection chrome + empty-state paths. +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + user: { id: 1, username: "test-admin", role: "admin" }, + status: "authenticated", + }), +})); + /** * Minimal `render` helper using react-dom/client + act(). Mirrors the * `renderHook` helper in `useReconciliation.test.ts` — see that file's diff --git a/src/pages/Reconciliation.tsx b/src/pages/Reconciliation.tsx index fd30ca0..dfe4ac3 100644 --- a/src/pages/Reconciliation.tsx +++ b/src/pages/Reconciliation.tsx @@ -9,6 +9,7 @@ import { ErrorState } from "@/components/ui/error-state"; import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { RoleGate } from "@/auth/RoleGate"; /** * Two-column manual reconciliation surface. The operator picks one row from @@ -191,14 +192,22 @@ export function ReconciliationPage() {
- + {/* Match selected is the only true write affordance on this + page — it transitions a claim from "submitted" / + "rejected" to "paid" / "partial" / "denied" / "received" + via `record_manual_match`. Viewers can still pick rows + and see the selection chrome, but the button itself is + disabled + tooltip-explained for them. */} + + +
-
{ - e.preventDefault(); - setDragging(true); - }} - onDragLeave={() => setDragging(false)} - onDrop={onDrop} - onClick={() => inputRef.current?.click()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); - }} - className={cn( - "relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors", - "px-6 py-12 min-h-[160px] cursor-pointer", - dragging - ? "border-accent bg-accent/5 ring-2 ring-accent/30" - : "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - )} - > - {/* Soft inner glow when dragging — a precision-instrument - "active" state. */} + {/* Write affordances — dropzone + Parse button — are gated by + RoleGate so a viewer-role account can SEE the configuration + controls (kind, payer) but can't actually ingest a file. */} + + +
Read-only access
+
+ Your role (viewer) cannot ingest new files. +
+
+ }>
{ + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); + }} className={cn( - "h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors", - dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground" + "relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors", + "px-6 py-12 min-h-[160px] cursor-pointer", + dragging + ? "border-accent bg-accent/5 ring-2 ring-accent/30" + : "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" )} > - -
- {file ? ( -
- - {file.name} - - · {formatBytes(file.size)} - + {/* Soft inner glow when dragging — a precision-instrument + "active" state. */} +
+
- ) : ( - <> -
- {dragging ? "Release to upload" : "Drop a file here, or click to choose"} + {file ? ( +
+ + {file.name} + + · {formatBytes(file.size)} +
-
- .txt — the X12 837/835 file as exported from your clearinghouse -
- - )} - pickFile(e.target.files?.[0] ?? null)} - /> -
+ ) : ( + <> +
+ {dragging ? "Release to upload" : "Drop a file here, or click to choose"} +
+
+ .txt — the X12 837/835 file as exported from your clearinghouse +
+ + )} + pickFile(e.target.files?.[0] ?? null)} + /> +
+
@@ -674,35 +688,41 @@ export function Upload() { )}
-
- {file ? ( + {/* Parse button — the second half of the write affordance. + Wrapping it independently so the dropzone's `fallback` + message doesn't sit next to a disabled-looking Parse + button when the role is viewer. */} + +
+ {file ? ( + + ) : null} - ) : null} - -
+
+