diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index 5ca40a0..fbafc7c 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -8,6 +8,27 @@ import Inbox from "./Inbox"; import * as inboxApi from "@/lib/inbox-api"; import * as downloadModule from "@/lib/download"; +// The Inbox's write-affordance BulkBars (resubmit / acknowledge / +// dismiss) are wrapped in RoleGate, which reads the auth context to +// decide whether the current user has write permission. We don't want +// the tests to spin up the full AuthProvider + /api/auth/me probe — +// instead we stub useAuth to return an admin user so RoleGate renders +// the BulkBars synchronously on first render. +vi.mock("@/auth/useAuth", () => ({ + useAuth: () => ({ + status: "authenticated" as const, + user: { + id: "test-admin", + username: "test-admin", + role: "admin" as const, + created_at: "2026-01-01T00:00:00Z", + }, + login: vi.fn(), + logout: vi.fn(), + refresh: vi.fn(), + }), +})); + // SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched // claim drilldown to /claims?claim=ID), so the render harness needs // a Router context. We use a fresh QueryClient per test so the diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 48bb075..b46d602 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -29,6 +29,7 @@ import { acknowledgePayerRejected, } from "@/lib/inbox-api"; import { downloadBlob } from "@/lib/download"; +import { RoleGate } from "@/auth/RoleGate"; type LaneKey = | "rejected" @@ -490,31 +491,43 @@ export default function Inbox() { - {/* Per-lane bulk bars. Each shows when its lane has a selection. */} - {}} - onDismiss={() => {}} - onExport={() => onExport("rejected")} - /> - {}} - onAcknowledge={onAcknowledge} - onDismiss={() => {}} - onExport={() => onExport("payer_rejected")} - /> - {}} - onAcknowledge={() => {}} - onDismiss={onDismiss} - onExport={() => onExport("candidates")} - /> + {/* Per-lane bulk bars. Each shows when its lane has a selection. + The three write-affordance BulkBars (rejected → resubmit, + payer_rejected → acknowledge, 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. */} + + {}} + onDismiss={() => {}} + onExport={() => onExport("rejected")} + /> + + + {}} + onAcknowledge={onAcknowledge} + onDismiss={() => {}} + onExport={() => onExport("payer_rejected")} + /> + + + {}} + onAcknowledge={() => {}} + onDismiss={onDismiss} + onExport={() => onExport("candidates")} + /> + ({ + useAuth: () => ({ + status: "authenticated" as const, + user: { + id: "test-admin", + username: "test-admin", + role: "admin" as const, + created_at: "2026-01-01T00:00:00Z", + }, + login: vi.fn(), + logout: vi.fn(), + refresh: vi.fn(), + }), +})); + // Module-level mock: vitest hoists `vi.mock` calls above imports. Only the // methods this page touches need to be stubbed; ApiError is re-exported so // the page can branch on .status in its catch handler. diff --git a/src/pages/Reconciliation.tsx b/src/pages/Reconciliation.tsx index b1b844b..aa718d8 100644 --- a/src/pages/Reconciliation.tsx +++ b/src/pages/Reconciliation.tsx @@ -21,6 +21,7 @@ import { RemitDrawer } from "@/components/RemitDrawer"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { fmt } from "@/lib/format"; import { cn } from "@/lib/utils"; +import { RoleGate } from "@/auth/RoleGate"; /** * Two-column manual reconciliation surface. The operator picks one row from @@ -613,14 +614,22 @@ export function ReconciliationPage() { Clear - + {/* 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 hidden for them. */} + + + diff --git a/src/pages/Upload.tsx b/src/pages/Upload.tsx index d20172e..2676c95 100644 --- a/src/pages/Upload.tsx +++ b/src/pages/Upload.tsx @@ -40,6 +40,7 @@ import type { ServicePayment, } from "@/types"; import { cn } from "@/lib/utils"; +import { RoleGate } from "@/auth/RoleGate"; // --------------------------------------------------------------------------- // Streaming state — every claim that arrives from the backend accumulates @@ -706,102 +707,120 @@ export function Upload() { - {/* Drop area — dashed border at idle, accent glow on drag */} -
{ - 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(); - }} - aria-label="Drop a file here, or click to choose" - className={cn( - "relative rounded-lg border border-dashed transition-all duration-200", - "px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden", - dragging - ? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]" - : "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border" - )} + {/* Write affordances — dropzone + Parse button — are gated by + RoleGate so a viewer-role account can SEE the configuration + controls (kind, payer) above but can't actually ingest a file. */} + + +
+ Read-only access +
+
+ Your role (viewer) cannot ingest new files. +
+
+ } > - {/* On drag — an accent scan-line sweeps across to reinforce - that the dropzone is hot. Pure CSS via the `animate-scan` - keyframe already in the Tailwind config. */} - {dragging ? ( -
+ {/* Drop area — dashed border at idle, accent glow on drag */} +
{ + 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(); + }} + aria-label="Drop a file here, or click to choose" + className={cn( + "relative rounded-lg border border-dashed transition-all duration-200", + "px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden", + dragging + ? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]" + : "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border" + )} + > + {/* On drag — an accent scan-line sweeps across to reinforce + that the dropzone is hot. Pure CSS via the `animate-scan` + keyframe already in the Tailwind config. */} + {dragging ? (
+
+
+ ) : null} + +
+
+ {dragging ? ( + + ) : ( + + )} +
+ {file ? ( +
+ + + {file.name} + + + · {formatBytes(file.size)} + +
+ ) : ( + <> +
+ {dragging ? ( + <> + Release to parse + + ) : ( + <> + Drop a file, or click to choose + + )} +
+
+ .txt — X12 837/835 as exported from your clearinghouse +
+ + )} + pickFile(e.target.files?.[0] ?? null)} />
- ) : null} - -
-
- {dragging ? ( - - ) : ( - - )} -
- {file ? ( -
- - - {file.name} - - - · {formatBytes(file.size)} - -
- ) : ( - <> -
- {dragging ? ( - <> - Release to parse - - ) : ( - <> - Drop a file, or click to choose - - )} -
-
- .txt — X12 837/835 as exported from your clearinghouse -
- - )} - pickFile(e.target.files?.[0] ?? null)} - />
-
+ {/* Action bar — backend status on the left, controls on the right */}
@@ -821,36 +840,42 @@ export function Upload() { )}
-
- {file ? ( + {/* Parse button — the second half of the write affordance. + Wrapped 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} - -
+
+