feat(auth): disable write affordances for viewer role

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 <RoleGate allow={admin,user}>:

  - 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.
This commit is contained in:
Nora
2026-06-22 15:54:38 -06:00
parent a25504bd3a
commit 35e0f5a422
5 changed files with 237 additions and 150 deletions
+21
View File
@@ -8,6 +8,27 @@ import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api"; import * as inboxApi from "@/lib/inbox-api";
import * as downloadModule from "@/lib/download"; 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 // SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
// claim drilldown to /claims?claim=ID), so the render harness needs // claim drilldown to /claims?claim=ID), so the render harness needs
// a Router context. We use a fresh QueryClient per test so the // a Router context. We use a fresh QueryClient per test so the
+38 -25
View File
@@ -29,6 +29,7 @@ import {
acknowledgePayerRejected, acknowledgePayerRejected,
} from "@/lib/inbox-api"; } from "@/lib/inbox-api";
import { downloadBlob } from "@/lib/download"; import { downloadBlob } from "@/lib/download";
import { RoleGate } from "@/auth/RoleGate";
type LaneKey = type LaneKey =
| "rejected" | "rejected"
@@ -490,31 +491,43 @@ export default function Inbox() {
</div> </div>
</section> </section>
{/* Per-lane bulk bars. Each shows when its lane has a selection. */} {/* Per-lane bulk bars. Each shows when its lane has a selection.
<BulkBar The three write-affordance BulkBars (rejected → resubmit,
lane="rejected" payer_rejected → acknowledge, candidates → dismiss) are gated
count={selected.rejected.length} by RoleGate so a viewer-role account can still SEE the rows
onResubmit={onResubmit} and select them but cannot trigger a state change. The export
onAcknowledge={() => {}} buttons live inside the same BulkBar component but are
onDismiss={() => {}} read-only, so we let them remain visible to viewers. */}
onExport={() => onExport("rejected")} <RoleGate allow={["admin", "user"]} fallback={null}>
/> <BulkBar
<BulkBar lane="rejected"
lane="payer_rejected" count={selected.rejected.length}
count={selected.payer_rejected.length} onResubmit={onResubmit}
onResubmit={() => {}} onAcknowledge={() => {}}
onAcknowledge={onAcknowledge} onDismiss={() => {}}
onDismiss={() => {}} onExport={() => onExport("rejected")}
onExport={() => onExport("payer_rejected")} />
/> </RoleGate>
<BulkBar <RoleGate allow={["admin", "user"]} fallback={null}>
lane="candidates" <BulkBar
count={selected.candidates.length} lane="payer_rejected"
onResubmit={() => {}} count={selected.payer_rejected.length}
onAcknowledge={() => {}} onResubmit={() => {}}
onDismiss={onDismiss} onAcknowledge={onAcknowledge}
onExport={() => onExport("candidates")} onDismiss={() => {}}
/> onExport={() => onExport("payer_rejected")}
/>
</RoleGate>
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
onAcknowledge={() => {}}
onDismiss={onDismiss}
onExport={() => onExport("candidates")}
/>
</RoleGate>
<BulkBar <BulkBar
lane="unmatched" lane="unmatched"
count={selected.unmatched.length} count={selected.unmatched.length}
+19
View File
@@ -12,6 +12,25 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReconciliationPage } from "./Reconciliation"; import { ReconciliationPage } from "./Reconciliation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
// "Match selected" is wrapped in RoleGate, which reads the auth
// context. Stub useAuth to return an admin user so the gate renders
// the button synchronously on first render — no AuthProvider + /me
// probe needed in the test harness.
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(),
}),
}));
// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the // 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 // methods this page touches need to be stubbed; ApiError is re-exported so
// the page can branch on .status in its catch handler. // the page can branch on .status in its catch handler.
+17 -8
View File
@@ -21,6 +21,7 @@ import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { fmt } from "@/lib/format"; import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RoleGate } from "@/auth/RoleGate";
/** /**
* Two-column manual reconciliation surface. The operator picks one row from * Two-column manual reconciliation surface. The operator picks one row from
@@ -613,14 +614,22 @@ export function ReconciliationPage() {
<X className="h-3.5 w-3.5 mr-1.5" /> <X className="h-3.5 w-3.5 mr-1.5" />
Clear Clear
</Button> </Button>
<Button {/* Match selected is the only true write affordance on
size="sm" this page — it transitions a claim from "submitted" /
onClick={handleMatch} "rejected" to "paid" / "partial" / "denied" /
disabled={!selectedClaim || !selectedRemit || match.isPending} "received" via `record_manual_match`. Viewers can
> still pick rows and see the selection chrome, but
<GitMerge className="h-3.5 w-3.5 mr-1.5" /> the button itself is hidden for them. */}
Match selected <RoleGate allow={["admin", "user"]}>
</Button> <Button
size="sm"
onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending}
>
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected
</Button>
</RoleGate>
</div> </div>
</div> </div>
</CardContent> </CardContent>
+142 -117
View File
@@ -40,6 +40,7 @@ import type {
ServicePayment, ServicePayment,
} from "@/types"; } from "@/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RoleGate } from "@/auth/RoleGate";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Streaming state — every claim that arrives from the backend accumulates // Streaming state — every claim that arrives from the backend accumulates
@@ -706,102 +707,120 @@ export function Upload() {
</div> </div>
</div> </div>
{/* Drop area — dashed border at idle, accent glow on drag */} {/* Write affordances — dropzone + Parse button — are gated by
<div RoleGate so a viewer-role account can SEE the configuration
onDragOver={(e) => { controls (kind, payer) above but can't actually ingest a file. */}
e.preventDefault(); <RoleGate
setDragging(true); allow={["admin", "user"]}
}} fallback={
onDragLeave={() => setDragging(false)} <div className="relative rounded-lg border border-dashed border-border/70 bg-muted/20 px-6 py-12 min-h-[200px] flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
onDrop={onDrop} <UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
onClick={() => inputRef.current?.click()} <div className="text-[13.5px] font-medium text-foreground">
role="button" Read-only access
tabIndex={0} </div>
onKeyDown={(e) => { <div className="mono text-[10.5px] uppercase tracking-[0.18em]">
if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); Your role (viewer) cannot ingest new files.
}} </div>
aria-label="Drop a file here, or click to choose" </div>
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 {/* Drop area — dashed border at idle, accent glow on drag */}
that the dropzone is hot. Pure CSS via the `animate-scan` <div
keyframe already in the Tailwind config. */} onDragOver={(e) => {
{dragging ? ( e.preventDefault();
<div setDragging(true);
aria-hidden }}
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg" 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 ? (
<div <div
className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan" aria-hidden
style={{ className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
background: >
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)", <div
}} className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan"
style={{
background:
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)",
}}
/>
</div>
) : null}
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
<div
className={cn(
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
dragging
? "bg-accent/15 text-accent ring-accent/40"
: "bg-muted/50 text-muted-foreground ring-border/60"
)}
>
{dragging ? (
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
) : (
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
)}
</div>
{file ? (
<div className="flex items-center gap-2 text-[13.5px] mt-1">
<FileText
className="h-4 w-4 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium text-foreground truncate max-w-[40ch]">
{file.name}
</span>
<span className="mono text-[11px] text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="display text-[20px] tracking-tight text-foreground mt-1">
{dragging ? (
<>
Release to <span className="italic text-accent">parse</span>
</>
) : (
<>
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
</>
)}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
.txt X12 837/835 as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/> />
</div> </div>
) : null}
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
<div
className={cn(
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
dragging
? "bg-accent/15 text-accent ring-accent/40"
: "bg-muted/50 text-muted-foreground ring-border/60"
)}
>
{dragging ? (
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
) : (
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
)}
</div>
{file ? (
<div className="flex items-center gap-2 text-[13.5px] mt-1">
<FileText
className="h-4 w-4 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium text-foreground truncate max-w-[40ch]">
{file.name}
</span>
<span className="mono text-[11px] text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div>
) : (
<>
<div className="display text-[20px] tracking-tight text-foreground mt-1">
{dragging ? (
<>
Release to <span className="italic text-accent">parse</span>
</>
) : (
<>
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
</>
)}
</div>
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
.txt X12 837/835 as exported from your clearinghouse
</div>
</>
)}
<input
ref={inputRef}
type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</div> </div>
</div> </RoleGate>
{/* Action bar — backend status on the left, controls on the right */} {/* Action bar — backend status on the left, controls on the right */}
<div className="mt-5 pt-5 border-t border-border/40 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mt-5 pt-5 border-t border-border/40 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -821,36 +840,42 @@ export function Upload() {
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> {/* Parse button — the second half of the write affordance.
{file ? ( Wrapped independently so the dropzone's `fallback`
message doesn't sit next to a disabled-looking Parse
button when the role is viewer. */}
<RoleGate allow={["admin", "user"]} fallback={null}>
<div className="flex items-center gap-2 flex-wrap">
{file ? (
<Button
variant="ghost"
size="sm"
onClick={() => pickFile(null)}
disabled={running}
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
) : null}
<Button <Button
variant="ghost" onClick={onParse}
disabled={!file || running}
size="sm" size="sm"
onClick={() => pickFile(null)}
disabled={running}
> >
<X className="h-3.5 w-3.5" /> {running ? (
Clear <>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse file
</>
)}
</Button> </Button>
) : null} </div>
<Button </RoleGate>
onClick={onParse}
disabled={!file || running}
size="sm"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse file
</>
)}
</Button>
</div>
</div> </div>
</section> </section>