feat(auth): disable write affordances for viewer role
This commit is contained in:
@@ -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();
|
||||
|
||||
+12
-1
@@ -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,7 +203,14 @@ export default function Inbox() {
|
||||
/>
|
||||
</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
|
||||
lane="rejected"
|
||||
count={selected.rejected.length}
|
||||
@@ -210,6 +218,8 @@ export default function Inbox() {
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("rejected")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||
<BulkBar
|
||||
lane="candidates"
|
||||
count={selected.candidates.length}
|
||||
@@ -217,6 +227,7 @@ export default function Inbox() {
|
||||
onDismiss={onDismiss}
|
||||
onExport={() => onExport("candidates")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<BulkBar
|
||||
lane="unmatched"
|
||||
count={selected.unmatched.length}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +192,13 @@ export function ReconciliationPage() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={handleMatch}
|
||||
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
||||
@@ -199,6 +207,7 @@ export function ReconciliationPage() {
|
||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||
Match selected
|
||||
</Button>
|
||||
</RoleGate>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
|
||||
@@ -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,6 +596,18 @@ export function Upload() {
|
||||
</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
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -656,6 +669,7 @@ export function Upload() {
|
||||
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,6 +688,11 @@ export function Upload() {
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
{file ? (
|
||||
<Button
|
||||
@@ -703,6 +722,7 @@ export function Upload() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user