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();
+12 -1
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,7 +203,14 @@ 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.
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 <BulkBar
lane="rejected" lane="rejected"
count={selected.rejected.length} count={selected.rejected.length}
@@ -210,6 +218,8 @@ export default function Inbox() {
onDismiss={() => {}} onDismiss={() => {}}
onExport={() => onExport("rejected")} onExport={() => onExport("rejected")}
/> />
</RoleGate>
<RoleGate allow={["admin", "user"]} fallback={null}>
<BulkBar <BulkBar
lane="candidates" lane="candidates"
count={selected.candidates.length} count={selected.candidates.length}
@@ -217,6 +227,7 @@ export default function Inbox() {
onDismiss={onDismiss} onDismiss={onDismiss}
onExport={() => onExport("candidates")} 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
+9
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,6 +192,13 @@ export function ReconciliationPage() {
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* 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 <Button
onClick={handleMatch} onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending} disabled={!selectedClaim || !selectedRemit || match.isPending}
@@ -199,6 +207,7 @@ export function ReconciliationPage() {
<GitMerge className="h-3.5 w-3.5 mr-1.5" /> <GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected Match selected
</Button> </Button>
</RoleGate>
<Button <Button
variant="outline" variant="outline"
onClick={handleClear} onClick={handleClear}
+20
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,6 +596,18 @@ export function Upload() {
</div> </div>
</div> </div>
{/* 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 <div
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
@@ -656,6 +669,7 @@ export function Upload() {
onChange={(e) => pickFile(e.target.files?.[0] ?? null)} onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
/> />
</div> </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,6 +688,11 @@ export function Upload() {
</span> </span>
)} )}
</div> </div>
{/* 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"> <div className="flex items-center gap-2 flex-wrap">
{file ? ( {file ? (
<Button <Button
@@ -703,6 +722,7 @@ export function Upload() {
)} )}
</Button> </Button>
</div> </div>
</RoleGate>
</div> </div>
</div> </div>