feat(auth): disable write affordances for viewer role
Re-apply f91d7b3 manually against current Upload.tsx / Inbox.tsx /
Reconciliation.tsx since main's UI text diverged from the original
cherry-pick (Release to 'parse' span, mono uppercase tracking, etc.).
The write affordances are now wrapped in <RoleGate allow={admin,user}>:
- Upload.tsx: dropzone + Parse/Clear buttons
- Inbox.tsx: rejected / payer_rejected / candidates BulkBars
- Reconciliation.tsx: 'Match selected' button
Test fixtures stub useAuth to return an admin user so RoleGate lets
the BulkBars / Match button render synchronously on first paint —
the tests don't need a full AuthProvider + /api/auth/me probe.
This commit is contained in:
@@ -8,6 +8,27 @@ import Inbox from "./Inbox";
|
||||
import * as inboxApi from "@/lib/inbox-api";
|
||||
import * as downloadModule from "@/lib/download";
|
||||
|
||||
// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
|
||||
// dismiss) are wrapped in RoleGate, which reads the auth context to
|
||||
// decide whether the current user has write permission. We don't want
|
||||
// the tests to spin up the full AuthProvider + /api/auth/me probe —
|
||||
// instead we stub useAuth to return an admin user so RoleGate renders
|
||||
// the BulkBars synchronously on first render.
|
||||
vi.mock("@/auth/useAuth", () => ({
|
||||
useAuth: () => ({
|
||||
status: "authenticated" as const,
|
||||
user: {
|
||||
id: "test-admin",
|
||||
username: "test-admin",
|
||||
role: "admin" as const,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
||||
// claim drilldown to /claims?claim=ID), so the render harness needs
|
||||
// a Router context. We use a fresh QueryClient per test so the
|
||||
|
||||
+14
-1
@@ -29,6 +29,7 @@ import {
|
||||
acknowledgePayerRejected,
|
||||
} from "@/lib/inbox-api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
import { RoleGate } from "@/auth/RoleGate";
|
||||
|
||||
type LaneKey =
|
||||
| "rejected"
|
||||
@@ -490,7 +491,14 @@ export default function Inbox() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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 three write-affordance BulkBars (rejected → resubmit,
|
||||
payer_rejected → acknowledge, 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}
|
||||
@@ -499,6 +507,8 @@ export default function Inbox() {
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("rejected")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||
<BulkBar
|
||||
lane="payer_rejected"
|
||||
count={selected.payer_rejected.length}
|
||||
@@ -507,6 +517,8 @@ export default function Inbox() {
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("payer_rejected")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||
<BulkBar
|
||||
lane="candidates"
|
||||
count={selected.candidates.length}
|
||||
@@ -515,6 +527,7 @@ export default function Inbox() {
|
||||
onDismiss={onDismiss}
|
||||
onExport={() => onExport("candidates")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<BulkBar
|
||||
lane="unmatched"
|
||||
count={selected.unmatched.length}
|
||||
|
||||
@@ -12,6 +12,25 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReconciliationPage } from "./Reconciliation";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// "Match selected" is wrapped in RoleGate, which reads the auth
|
||||
// context. Stub useAuth to return an admin user so the gate renders
|
||||
// the button synchronously on first render — no AuthProvider + /me
|
||||
// probe needed in the test harness.
|
||||
vi.mock("@/auth/useAuth", () => ({
|
||||
useAuth: () => ({
|
||||
status: "authenticated" as const,
|
||||
user: {
|
||||
id: "test-admin",
|
||||
username: "test-admin",
|
||||
role: "admin" as const,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the
|
||||
// methods this page touches need to be stubbed; ApiError is re-exported so
|
||||
// the page can branch on .status in its catch handler.
|
||||
|
||||
@@ -21,6 +21,7 @@ import { RemitDrawer } from "@/components/RemitDrawer";
|
||||
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RoleGate } from "@/auth/RoleGate";
|
||||
|
||||
/**
|
||||
* Two-column manual reconciliation surface. The operator picks one row from
|
||||
@@ -613,6 +614,13 @@ export function ReconciliationPage() {
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Clear
|
||||
</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 hidden for them. */}
|
||||
<RoleGate allow={["admin", "user"]}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleMatch}
|
||||
@@ -621,6 +629,7 @@ export function ReconciliationPage() {
|
||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||
Match selected
|
||||
</Button>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -40,6 +40,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
|
||||
@@ -706,6 +707,23 @@ 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) above but can't actually ingest a file. */}
|
||||
<RoleGate
|
||||
allow={["admin", "user"]}
|
||||
fallback={
|
||||
<div className="relative rounded-lg border border-dashed border-border/70 bg-muted/20 px-6 py-12 min-h-[200px] 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-[10.5px] uppercase tracking-[0.18em]">
|
||||
Your role (viewer) cannot ingest new files.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Drop area — dashed border at idle, accent glow on drag */}
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
@@ -802,6 +820,7 @@ export function Upload() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RoleGate>
|
||||
|
||||
{/* Action bar — backend status on the left, controls on the right */}
|
||||
<div className="mt-5 pt-5 border-t border-border/40 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -821,6 +840,11 @@ export function Upload() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Parse button — the second half of the write affordance.
|
||||
Wrapped 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
|
||||
@@ -851,6 +875,7 @@ export function Upload() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user