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 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(() => {
cleanup();
vi.unstubAllGlobals();
+26 -15
View File
@@ -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() {
/>
</main>
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
<BulkBar
lane="rejected"
count={selected.rejected.length}
onResubmit={onResubmit}
onDismiss={() => {}}
onExport={() => onExport("rejected")}
/>
<BulkBar
lane="candidates"
count={selected.candidates.length}
onResubmit={() => {}}
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. */}
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar
lane="rejected"
count={selected.rejected.length}
onResubmit={onResubmit}
onDismiss={() => {}}
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
lane="unmatched"
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
* `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 { 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() {
</div>
<div className="flex items-center gap-2 flex-wrap">
<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>
{/* 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. */}
<RoleGate allow={["admin", "user"]}>
<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
variant="outline"
onClick={handleClear}
+100 -80
View File
@@ -32,6 +32,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
@@ -595,67 +596,80 @@ export function Upload() {
</div>
</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(
"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. */}
<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">
<UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
<div className="text-[13.5px] font-medium text-foreground">Read-only access</div>
<div className="mono text-[11px]">
Your role (viewer) cannot ingest new files.
</div>
</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(
"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"
)}
>
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
{file ? (
<div className="flex items-center gap-2 text-[13px]">
<FileText
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>
{/* Soft inner glow when dragging — a precision-instrument
"active" state. */}
<div
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"
)}
>
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
) : (
<>
<div className="text-[13.5px] font-medium">
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
{file ? (
<div className="flex items-center gap-2 text-[13px]">
<FileText
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 className="mono text-[11px] text-muted-foreground">
.txt the X12 837/835 file 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 className="text-[13.5px] font-medium">
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
</div>
<div className="mono text-[11px] text-muted-foreground">
.txt the X12 837/835 file 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>
</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="mono text-[11px] text-muted-foreground">
@@ -674,35 +688,41 @@ export function Upload() {
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{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. */}
<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
variant="ghost"
onClick={() => pickFile(null)}
disabled={running}
onClick={onParse}
disabled={!file || running}
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>
) : null}
<Button
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>
</RoleGate>
</div>
</div>