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:
+142
-117
@@ -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,102 +707,120 @@ export function Upload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop area — dashed border at idle, accent glow on drag */}
|
||||
<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();
|
||||
}}
|
||||
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"
|
||||
)}
|
||||
{/* 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>
|
||||
}
|
||||
>
|
||||
{/* 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
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
|
||||
>
|
||||
{/* Drop area — dashed border at idle, accent glow on drag */}
|
||||
<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();
|
||||
}}
|
||||
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
|
||||
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)",
|
||||
}}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden rounded-lg"
|
||||
>
|
||||
<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>
|
||||
) : 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>
|
||||
</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,36 +840,42 @@ export function Upload() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{file ? (
|
||||
{/* 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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => pickFile(null)}
|
||||
disabled={running}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onParse}
|
||||
disabled={!file || running}
|
||||
size="sm"
|
||||
onClick={() => pickFile(null)}
|
||||
disabled={running}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Clear
|
||||
{running ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Parsing…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="h-3.5 w-3.5" />
|
||||
Parse file
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
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>
|
||||
</RoleGate>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user