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 inboxApi from "@/lib/inbox-api";
|
||||||
import * as downloadModule from "@/lib/download";
|
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
|
// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
|
||||||
// claim drilldown to /claims?claim=ID), so the render harness needs
|
// claim drilldown to /claims?claim=ID), so the render harness needs
|
||||||
// a Router context. We use a fresh QueryClient per test so the
|
// a Router context. We use a fresh QueryClient per test so the
|
||||||
|
|||||||
+38
-25
@@ -29,6 +29,7 @@ import {
|
|||||||
acknowledgePayerRejected,
|
acknowledgePayerRejected,
|
||||||
} 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 =
|
type LaneKey =
|
||||||
| "rejected"
|
| "rejected"
|
||||||
@@ -490,31 +491,43 @@ export default function Inbox() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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.
|
||||||
<BulkBar
|
The three write-affordance BulkBars (rejected → resubmit,
|
||||||
lane="rejected"
|
payer_rejected → acknowledge, candidates → dismiss) are gated
|
||||||
count={selected.rejected.length}
|
by RoleGate so a viewer-role account can still SEE the rows
|
||||||
onResubmit={onResubmit}
|
and select them but cannot trigger a state change. The export
|
||||||
onAcknowledge={() => {}}
|
buttons live inside the same BulkBar component but are
|
||||||
onDismiss={() => {}}
|
read-only, so we let them remain visible to viewers. */}
|
||||||
onExport={() => onExport("rejected")}
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
/>
|
<BulkBar
|
||||||
<BulkBar
|
lane="rejected"
|
||||||
lane="payer_rejected"
|
count={selected.rejected.length}
|
||||||
count={selected.payer_rejected.length}
|
onResubmit={onResubmit}
|
||||||
onResubmit={() => {}}
|
onAcknowledge={() => {}}
|
||||||
onAcknowledge={onAcknowledge}
|
onDismiss={() => {}}
|
||||||
onDismiss={() => {}}
|
onExport={() => onExport("rejected")}
|
||||||
onExport={() => onExport("payer_rejected")}
|
/>
|
||||||
/>
|
</RoleGate>
|
||||||
<BulkBar
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
lane="candidates"
|
<BulkBar
|
||||||
count={selected.candidates.length}
|
lane="payer_rejected"
|
||||||
onResubmit={() => {}}
|
count={selected.payer_rejected.length}
|
||||||
onAcknowledge={() => {}}
|
onResubmit={() => {}}
|
||||||
onDismiss={onDismiss}
|
onAcknowledge={onAcknowledge}
|
||||||
onExport={() => onExport("candidates")}
|
onDismiss={() => {}}
|
||||||
/>
|
onExport={() => onExport("payer_rejected")}
|
||||||
|
/>
|
||||||
|
</RoleGate>
|
||||||
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
|
<BulkBar
|
||||||
|
lane="candidates"
|
||||||
|
count={selected.candidates.length}
|
||||||
|
onResubmit={() => {}}
|
||||||
|
onAcknowledge={() => {}}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onExport={() => onExport("candidates")}
|
||||||
|
/>
|
||||||
|
</RoleGate>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="unmatched"
|
lane="unmatched"
|
||||||
count={selected.unmatched.length}
|
count={selected.unmatched.length}
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { ReconciliationPage } from "./Reconciliation";
|
import { ReconciliationPage } from "./Reconciliation";
|
||||||
import { api } from "@/lib/api";
|
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
|
// 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
|
// methods this page touches need to be stubbed; ApiError is re-exported so
|
||||||
// the page can branch on .status in its catch handler.
|
// 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 { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
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
|
||||||
@@ -613,14 +614,22 @@ export function ReconciliationPage() {
|
|||||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/* Match selected is the only true write affordance on
|
||||||
size="sm"
|
this page — it transitions a claim from "submitted" /
|
||||||
onClick={handleMatch}
|
"rejected" to "paid" / "partial" / "denied" /
|
||||||
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
"received" via `record_manual_match`. Viewers can
|
||||||
>
|
still pick rows and see the selection chrome, but
|
||||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
the button itself is hidden for them. */}
|
||||||
Match selected
|
<RoleGate allow={["admin", "user"]}>
|
||||||
</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMatch}
|
||||||
|
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
||||||
|
>
|
||||||
|
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Match selected
|
||||||
|
</Button>
|
||||||
|
</RoleGate>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+142
-117
@@ -40,6 +40,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
|
||||||
@@ -706,102 +707,120 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop area — dashed border at idle, accent glow on drag */}
|
{/* Write affordances — dropzone + Parse button — are gated by
|
||||||
<div
|
RoleGate so a viewer-role account can SEE the configuration
|
||||||
onDragOver={(e) => {
|
controls (kind, payer) above but can't actually ingest a file. */}
|
||||||
e.preventDefault();
|
<RoleGate
|
||||||
setDragging(true);
|
allow={["admin", "user"]}
|
||||||
}}
|
fallback={
|
||||||
onDragLeave={() => setDragging(false)}
|
<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">
|
||||||
onDrop={onDrop}
|
<UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
|
||||||
onClick={() => inputRef.current?.click()}
|
<div className="text-[13.5px] font-medium text-foreground">
|
||||||
role="button"
|
Read-only access
|
||||||
tabIndex={0}
|
</div>
|
||||||
onKeyDown={(e) => {
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em]">
|
||||||
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
Your role (viewer) cannot ingest new files.
|
||||||
}}
|
</div>
|
||||||
aria-label="Drop a file here, or click to choose"
|
</div>
|
||||||
className={cn(
|
}
|
||||||
"relative rounded-lg border border-dashed transition-all duration-200",
|
|
||||||
"px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden",
|
|
||||||
dragging
|
|
||||||
? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]"
|
|
||||||
: "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* On drag — an accent scan-line sweeps across to reinforce
|
{/* Drop area — dashed border at idle, accent glow on drag */}
|
||||||
that the dropzone is hot. Pure CSS via the `animate-scan`
|
<div
|
||||||
keyframe already in the Tailwind config. */}
|
onDragOver={(e) => {
|
||||||
{dragging ? (
|
e.preventDefault();
|
||||||
<div
|
setDragging(true);
|
||||||
aria-hidden
|
}}
|
||||||
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
|
onDragLeave={() => setDragging(false)}
|
||||||
>
|
onDrop={onDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
||||||
|
}}
|
||||||
|
aria-label="Drop a file here, or click to choose"
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-lg border border-dashed transition-all duration-200",
|
||||||
|
"px-6 py-12 min-h-[200px] cursor-pointer overflow-hidden",
|
||||||
|
dragging
|
||||||
|
? "border-accent bg-accent/[0.06] shadow-[inset_0_0_0_1px_hsl(var(--accent)/0.35),0_0_0_4px_hsl(var(--accent)/0.08)]"
|
||||||
|
: "border-border/70 bg-background/40 hover:bg-background/60 hover:border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* On drag — an accent scan-line sweeps across to reinforce
|
||||||
|
that the dropzone is hot. Pure CSS via the `animate-scan`
|
||||||
|
keyframe already in the Tailwind config. */}
|
||||||
|
{dragging ? (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan"
|
aria-hidden
|
||||||
style={{
|
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
|
||||||
background:
|
>
|
||||||
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)",
|
<div
|
||||||
}}
|
className="absolute inset-y-0 -left-1/3 w-1/3 animate-scan"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent, hsl(var(--accent) / 0.18), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
|
||||||
|
dragging
|
||||||
|
? "bg-accent/15 text-accent ring-accent/40"
|
||||||
|
: "bg-muted/50 text-muted-foreground ring-border/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dragging ? (
|
||||||
|
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{file ? (
|
||||||
|
<div className="flex items-center gap-2 text-[13.5px] mt-1">
|
||||||
|
<FileText
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-foreground truncate max-w-[40ch]">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[11px] text-muted-foreground">
|
||||||
|
· {formatBytes(file.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="display text-[20px] tracking-tight text-foreground mt-1">
|
||||||
|
{dragging ? (
|
||||||
|
<>
|
||||||
|
Release to <span className="italic text-accent">parse</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
||||||
|
.txt — X12 837/835 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>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="relative flex flex-col items-center justify-center gap-2.5 text-center">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-11 w-11 rounded-md ring-1 ring-inset flex items-center justify-center transition-colors",
|
|
||||||
dragging
|
|
||||||
? "bg-accent/15 text-accent ring-accent/40"
|
|
||||||
: "bg-muted/50 text-muted-foreground ring-border/60"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{dragging ? (
|
|
||||||
<CloudUpload className="h-5 w-5" strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<UploadIcon className="h-5 w-5" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{file ? (
|
|
||||||
<div className="flex items-center gap-2 text-[13.5px] mt-1">
|
|
||||||
<FileText
|
|
||||||
className="h-4 w-4 text-muted-foreground"
|
|
||||||
strokeWidth={1.75}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-foreground truncate max-w-[40ch]">
|
|
||||||
{file.name}
|
|
||||||
</span>
|
|
||||||
<span className="mono text-[11px] text-muted-foreground">
|
|
||||||
· {formatBytes(file.size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="display text-[20px] tracking-tight text-foreground mt-1">
|
|
||||||
{dragging ? (
|
|
||||||
<>
|
|
||||||
Release to <span className="italic text-accent">parse</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Drop a file, or click to <span className="italic text-muted-foreground/80">choose</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
|
||||||
.txt — X12 837/835 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>
|
||||||
</div>
|
</RoleGate>
|
||||||
|
|
||||||
{/* Action bar — backend status on the left, controls on the right */}
|
{/* 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">
|
<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,36 +840,42 @@ export function Upload() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{/* Parse button — the second half of the write affordance.
|
||||||
{file ? (
|
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
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => pickFile(null)}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
onClick={onParse}
|
||||||
|
disabled={!file || running}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => pickFile(null)}
|
|
||||||
disabled={running}
|
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
{running ? (
|
||||||
Clear
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Parsing…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadIcon className="h-3.5 w-3.5" />
|
||||||
|
Parse file
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
<Button
|
</RoleGate>
|
||||||
onClick={onParse}
|
|
||||||
disabled={!file || running}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{running ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
Parsing…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<UploadIcon className="h-3.5 w-3.5" />
|
|
||||||
Parse file
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user