From 35e0f5a422e45dc1dcf1c07c6f8e9ee30f472c42 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 15:54:38 -0600 Subject: [PATCH] feat(auth): disable write affordances for viewer role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx / Reconciliation.tsx since main's UI text diverged from the original cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.). The write affordances are now wrapped in : - Upload.tsx: dropzone + Parse/Clear buttons - Inbox.tsx: rejected / payer_rejected / candidates BulkBars - Reconciliation.tsx: 'Match selected' button Test fixtures stub useAuth to return an admin user so RoleGate lets the BulkBars / Match button render synchronously on first paint — the tests don't need a full AuthProvider + /api/auth/me probe. --- src/pages/Inbox.test.tsx | 21 +++ src/pages/Inbox.tsx | 63 +++++--- src/pages/Reconciliation.test.tsx | 19 +++ src/pages/Reconciliation.tsx | 25 ++- src/pages/Upload.tsx | 259 ++++++++++++++++-------------- 5 files changed, 237 insertions(+), 150 deletions(-) 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} - -
+
+