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();
|
||||
|
||||
+26
-15
@@ -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,21 +203,31 @@ export default function Inbox() {
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Per-lane bulk bars. Each shows when its lane has a selection. */}
|
||||
<BulkBar
|
||||
lane="rejected"
|
||||
count={selected.rejected.length}
|
||||
onResubmit={onResubmit}
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("rejected")}
|
||||
/>
|
||||
<BulkBar
|
||||
lane="candidates"
|
||||
count={selected.candidates.length}
|
||||
onResubmit={() => {}}
|
||||
onDismiss={onDismiss}
|
||||
onExport={() => onExport("candidates")}
|
||||
/>
|
||||
{/* 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}
|
||||
onResubmit={onResubmit}
|
||||
onDismiss={() => {}}
|
||||
onExport={() => onExport("rejected")}
|
||||
/>
|
||||
</RoleGate>
|
||||
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||
<BulkBar
|
||||
lane="candidates"
|
||||
count={selected.candidates.length}
|
||||
onResubmit={() => {}}
|
||||
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,14 +192,22 @@ export function ReconciliationPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={handleMatch}
|
||||
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||
Match selected
|
||||
</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
|
||||
disabled + tooltip-explained for them. */}
|
||||
<RoleGate allow={["admin", "user"]}>
|
||||
<Button
|
||||
onClick={handleMatch}
|
||||
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
||||
Match selected
|
||||
</Button>
|
||||
</RoleGate>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
|
||||
+100
-80
@@ -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,67 +596,80 @@ export function Upload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
|
||||
"px-6 py-12 min-h-[160px] cursor-pointer",
|
||||
dragging
|
||||
? "border-accent bg-accent/5 ring-2 ring-accent/30"
|
||||
: "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
{/* Soft inner glow when dragging — a precision-instrument
|
||||
"active" state. */}
|
||||
{/* 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();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
||||
}}
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
|
||||
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
|
||||
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
|
||||
"px-6 py-12 min-h-[160px] cursor-pointer",
|
||||
dragging
|
||||
? "border-accent bg-accent/5 ring-2 ring-accent/30"
|
||||
: "border-border/60 bg-muted/20 hover:bg-muted/30 hover:border-border",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<FileText
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="mono text-muted-foreground">
|
||||
· {formatBytes(file.size)}
|
||||
</span>
|
||||
{/* Soft inner glow when dragging — a precision-instrument
|
||||
"active" state. */}
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
|
||||
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[13.5px] font-medium">
|
||||
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
|
||||
{file ? (
|
||||
<div className="flex items-center gap-2 text-[13px]">
|
||||
<FileText
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="mono text-muted-foreground">
|
||||
· {formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mono text-[11px] text-muted-foreground">
|
||||
.txt — the X12 837/835 file 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 className="text-[13.5px] font-medium">
|
||||
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
|
||||
</div>
|
||||
<div className="mono text-[11px] text-muted-foreground">
|
||||
.txt — the X12 837/835 file 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>
|
||||
</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,35 +688,41 @@ export function Upload() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{file ? (
|
||||
{/* 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
|
||||
variant="ghost"
|
||||
onClick={() => pickFile(null)}
|
||||
disabled={running}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => pickFile(null)}
|
||||
disabled={running}
|
||||
onClick={onParse}
|
||||
disabled={!file || running}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
Clear
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Parsing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="h-3.5 w-3.5" />
|
||||
Parse
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={onParse}
|
||||
disabled={!file || running}
|
||||
className="min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Parsing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="h-3.5 w-3.5" />
|
||||
Parse
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user