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 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
@@ -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}
|
||||||
|
|||||||
@@ -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,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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user