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();
|
||||||
|
|||||||
+26
-15
@@ -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,21 +203,31 @@ 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.
|
||||||
<BulkBar
|
The two write-affordance BulkBars (rejected → resubmit,
|
||||||
lane="rejected"
|
candidates → dismiss) are gated by RoleGate so a viewer-role
|
||||||
count={selected.rejected.length}
|
account can still SEE the rows and select them but cannot
|
||||||
onResubmit={onResubmit}
|
trigger a state change. The export buttons live inside the
|
||||||
onDismiss={() => {}}
|
same BulkBar component but are read-only, so we let them
|
||||||
onExport={() => onExport("rejected")}
|
remain visible to viewers. */}
|
||||||
/>
|
<RoleGate allow={["admin", "user"]} fallback={null}>
|
||||||
<BulkBar
|
<BulkBar
|
||||||
lane="candidates"
|
lane="rejected"
|
||||||
count={selected.candidates.length}
|
count={selected.rejected.length}
|
||||||
onResubmit={() => {}}
|
onResubmit={onResubmit}
|
||||||
onDismiss={onDismiss}
|
onDismiss={() => {}}
|
||||||
onExport={() => onExport("candidates")}
|
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
|
<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,14 +192,22 @@ export function ReconciliationPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button
|
{/* Match selected is the only true write affordance on this
|
||||||
onClick={handleMatch}
|
page — it transitions a claim from "submitted" /
|
||||||
disabled={!selectedClaim || !selectedRemit || match.isPending}
|
"rejected" to "paid" / "partial" / "denied" / "received"
|
||||||
className="min-h-[44px] sm:min-h-0"
|
via `record_manual_match`. Viewers can still pick rows
|
||||||
>
|
and see the selection chrome, but the button itself is
|
||||||
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
|
disabled + tooltip-explained for them. */}
|
||||||
Match selected
|
<RoleGate allow={["admin", "user"]}>
|
||||||
</Button>
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
|
|||||||
+100
-80
@@ -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,67 +596,80 @@ export function Upload() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Write affordances — dropzone + Parse button — are gated by
|
||||||
onDragOver={(e) => {
|
RoleGate so a viewer-role account can SEE the configuration
|
||||||
e.preventDefault();
|
controls (kind, payer) but can't actually ingest a file. */}
|
||||||
setDragging(true);
|
<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">
|
||||||
onDragLeave={() => setDragging(false)}
|
<UploadIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden />
|
||||||
onDrop={onDrop}
|
<div className="text-[13.5px] font-medium text-foreground">Read-only access</div>
|
||||||
onClick={() => inputRef.current?.click()}
|
<div className="mono text-[11px]">
|
||||||
role="button"
|
Your role (viewer) cannot ingest new files.
|
||||||
tabIndex={0}
|
</div>
|
||||||
onKeyDown={(e) => {
|
</div>
|
||||||
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. */}
|
|
||||||
<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(
|
className={cn(
|
||||||
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
|
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors",
|
||||||
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
|
"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} />
|
{/* Soft inner glow when dragging — a precision-instrument
|
||||||
</div>
|
"active" state. */}
|
||||||
{file ? (
|
<div
|
||||||
<div className="flex items-center gap-2 text-[13px]">
|
className={cn(
|
||||||
<FileText
|
"h-10 w-10 rounded-md ring-1 ring-inset ring-border/60 flex items-center justify-center transition-colors",
|
||||||
className="h-3.5 w-3.5 text-muted-foreground"
|
dragging ? "bg-accent/15 text-accent ring-accent/40" : "bg-muted/30 text-muted-foreground"
|
||||||
strokeWidth={1.75}
|
)}
|
||||||
/>
|
>
|
||||||
<span className="font-medium">{file.name}</span>
|
<UploadIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||||
<span className="mono text-muted-foreground">
|
|
||||||
· {formatBytes(file.size)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{file ? (
|
||||||
<>
|
<div className="flex items-center gap-2 text-[13px]">
|
||||||
<div className="text-[13.5px] font-medium">
|
<FileText
|
||||||
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
|
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>
|
||||||
<div className="mono text-[11px] text-muted-foreground">
|
) : (
|
||||||
.txt — the X12 837/835 file as exported from your clearinghouse
|
<>
|
||||||
</div>
|
<div className="text-[13.5px] font-medium">
|
||||||
</>
|
{dragging ? "Release to upload" : "Drop a file here, or click to choose"}
|
||||||
)}
|
</div>
|
||||||
<input
|
<div className="mono text-[11px] text-muted-foreground">
|
||||||
ref={inputRef}
|
.txt — the X12 837/835 file as exported from your clearinghouse
|
||||||
type="file"
|
</div>
|
||||||
accept=".txt"
|
</>
|
||||||
className="sr-only"
|
)}
|
||||||
onChange={(e) => pickFile(e.target.files?.[0] ?? null)}
|
<input
|
||||||
/>
|
ref={inputRef}
|
||||||
</div>
|
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="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,35 +688,41 @@ 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 ? (
|
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
|
<Button
|
||||||
variant="ghost"
|
onClick={onParse}
|
||||||
onClick={() => pickFile(null)}
|
disabled={!file || running}
|
||||||
disabled={running}
|
|
||||||
className="min-h-[44px] sm:min-h-0"
|
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>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
<Button
|
</RoleGate>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user