feat(auth): disable write affordances for viewer role

This commit is contained in:
Nora
2026-06-22 15:20:05 -06:00
parent b8b679b108
commit f91d7b3b46
5 changed files with 166 additions and 103 deletions
+11
View File
@@ -5,6 +5,17 @@ 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 page now gates the Resubmit/Dismiss BulkBars behind
// <RoleGate allow={["admin", "user"]}>. 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(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
+26 -15
View File
@@ -18,6 +18,7 @@ import {
resubmitRejectedWithDownload, resubmitRejectedWithDownload,
} 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 = "rejected" | "candidates" | "unmatched" | "done_today"; type LaneKey = "rejected" | "candidates" | "unmatched" | "done_today";
@@ -202,21 +203,31 @@ export default function Inbox() {
/> />
</main> </main>
{/* 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 two write-affordance BulkBars (rejected → resubmit,
lane="rejected" candidates → dismiss) are gated by RoleGate so a viewer-role
count={selected.rejected.length} account can still SEE the rows and select them but cannot
onResubmit={onResubmit} trigger a state change. The export buttons live inside the
onDismiss={() => {}} same BulkBar component but are read-only, so we let them
onExport={() => onExport("rejected")} remain visible to viewers. */}
/> <RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar <BulkBar
lane="candidates" lane="rejected"
count={selected.candidates.length} count={selected.rejected.length}
onResubmit={() => {}} onResubmit={onResubmit}
onDismiss={onDismiss} onDismiss={() => {}}
onExport={() => onExport("candidates")} onExport={() => onExport("rejected")}
/> />
</RoleGate>
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
onDismiss={onDismiss}
onExport={() => onExport("candidates")}
/>
</RoleGate>
<BulkBar <BulkBar
lane="unmatched" lane="unmatched"
count={selected.unmatched.length} count={selected.unmatched.length}
+12
View File
@@ -27,6 +27,18 @@ vi.mock("@/lib/api", () => ({
}, },
})); }));
// Match selected is now behind <RoleGate allow={["admin", "user"]}>.
// The renderIntoContainer helper does not wrap in <AuthProvider>,
// 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 * Minimal `render` helper using react-dom/client + act(). Mirrors the
* `renderHook` helper in `useReconciliation.test.ts` — see that file's * `renderHook` helper in `useReconciliation.test.ts` — see that file's
+17 -8
View File
@@ -9,6 +9,7 @@ import { ErrorState } from "@/components/ui/error-state";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
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
@@ -191,14 +192,22 @@ export function ReconciliationPage() {
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Button {/* Match selected is the only true write affordance on this
onClick={handleMatch} page — it transitions a claim from "submitted" /
disabled={!selectedClaim || !selectedRemit || match.isPending} "rejected" to "paid" / "partial" / "denied" / "received"
className="min-h-[44px] sm:min-h-0" via `record_manual_match`. Viewers can still pick rows
> and see the selection chrome, but the button itself is
<GitMerge className="h-3.5 w-3.5 mr-1.5" /> disabled + tooltip-explained for them. */}
Match selected <RoleGate allow={["admin", "user"]}>
</Button> <Button
onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending}
className="min-h-[44px] sm:min-h-0"
>
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected
</Button>
</RoleGate>
<Button <Button
variant="outline" variant="outline"
onClick={handleClear} onClick={handleClear}
+100 -80
View File
@@ -32,6 +32,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
@@ -595,67 +596,80 @@ export function Upload() {
</div> </div>
</div> </div>
<div {/* Write affordances — dropzone + Parse button — are gated by
onDragOver={(e) => { RoleGate so a viewer-role account can SEE the configuration
e.preventDefault(); controls (kind, payer) but can't actually ingest a file. */}
setDragging(true); <RoleGate allow={["admin", "user"]} fallback={
}} <div className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-6 py-12 min-h-[160px] flex flex-col items-center justify-center gap-2 text-center text-muted-foreground">
onDragLeave={() => setDragging(false)} <UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
onDrop={onDrop} <div className="text-[13.5px] font-medium text-foreground">Read-only access</div>
onClick={() => inputRef.current?.click()} <div className="mono text-[11px]">
role="button" Your role (viewer) cannot ingest new files.
tabIndex={0} </div>
onKeyDown={(e) => { </div>
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. */}
<div <div
onDragOver={(e) => {
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( className={cn(
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors", "relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground" "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"
)} )}
> >
<UploadIcon className="h-4 w-4" strokeWidth={1.5} /> {/* Soft inner glow when dragging — a precision-instrument
</div> "active" state. */}
{file ? ( <div
<div className="flex items-center gap-2 text-[13px]"> className={cn(
<FileText "h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
className="h-3.5 w-3.5 text-muted-foreground" dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
strokeWidth={1.75} )}
/> >
<span className="font-medium">{file.name}</span> <UploadIcon className="h-4 w-4" strokeWidth={1.5} />
<span className="mono text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div> </div>
) : ( {file ? (
<> <div className="flex items-center gap-2 text-[13px]">
<div className="text-[13.5px] font-medium"> <FileText
{dragging ? "Release to upload" : "Drop a file here, or click to choose"} className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.75}
/>
<span className="font-medium">{file.name}</span>
<span className="mono text-muted-foreground">
· {formatBytes(file.size)}
</span>
</div> </div>
<div className="mono text-[11px] text-muted-foreground"> ) : (
.txt the X12 837/835 file as exported from your clearinghouse <>
</div> <div className="text-[13.5px] font-medium">
</> {dragging ? "Release to upload" : "Drop a file here, or click to choose"}
)} </div>
<input <div className="mono text-[11px] text-muted-foreground">
ref={inputRef} .txt the X12 837/835 file as exported from your clearinghouse
type="file" </div>
accept=".txt" </>
className="sr-only" )}
onChange={(e) => pickFile(e.target.files?.[0] ?? null)} <input
/> ref={inputRef}
</div> type="file"
accept=".txt"
className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/>
</div>
</RoleGate>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mt-4 pt-4 border-t border-border/30"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mt-4 pt-4 border-t border-border/30">
<div className="mono text-[11px] text-muted-foreground"> <div className="mono text-[11px] text-muted-foreground">
@@ -674,35 +688,41 @@ 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 ? ( Wrapping it 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"
onClick={() => pickFile(null)}
disabled={running}
className="min-h-[44px] sm:min-h-0"
>
Clear
</Button>
) : null}
<Button <Button
variant="ghost" onClick={onParse}
onClick={() => pickFile(null)} disabled={!file || running}
disabled={running}
className="min-h-[44px] sm:min-h-0" className="min-h-[44px] sm:min-h-0"
> >
Clear {running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button> </Button>
) : null} </div>
<Button </RoleGate>
onClick={onParse}
disabled={!file || running}
className="min-h-[44px] sm:min-h-0"
>
{running ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Parsing
</>
) : (
<>
<UploadIcon className="h-3.5 w-3.5" />
Parse
</>
)}
</Button>
</div>
</div> </div>
</div> </div>