From fd7107290a89de78d075b0f01b5e24b30d5a2b81 Mon Sep 17 00:00:00 2001 From: renee-png Date: Sun, 7 Jun 2026 12:28:03 -0400 Subject: [PATCH] Bill approvals: admins-only mark-paid + DB guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict marking a bill paid to admins only, per requirement. - BillDetailPage: gate Mark Paid / Mark Unpaid on useAuth().isAdmin (was only hidden in board view). - BillApprovalsPage: gate Print Checks (which sets bills to paid) on isAdmin. - Migration: BEFORE INSERT/UPDATE trigger enforce_admin_marks_bill_paid() rejects the transition into 'paid' for authenticated non-admins. Service-role / system contexts (auth.uid() null: buildium-sync, accounting triggers, autopay) remain allowed. Verified: admin allowed, non-admin blocked (23514). Note: the approver column showing "None" in production is a stale-deploy issue — the DB column was renamed vendor_name->approver_name (Jun 4) but prod still ran code querying vendor_name. Deploying current main resolves it. Co-Authored-By: Claude Opus 4.8 --- src/pages/BillApprovalsPage.tsx | 24 +++++++------ src/pages/BillDetailPage.tsx | 6 ++-- ...0607160000_only_admins_mark_bills_paid.sql | 34 +++++++++++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 supabase/migrations/20260607160000_only_admins_mark_bills_paid.sql diff --git a/src/pages/BillApprovalsPage.tsx b/src/pages/BillApprovalsPage.tsx index 356d11b..5217dc5 100644 --- a/src/pages/BillApprovalsPage.tsx +++ b/src/pages/BillApprovalsPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/contexts/AuthContext"; import { UserCheck, Plus, Search, Eye, Upload, X, ArrowUpDown, Edit, Trash2, MoreHorizontal, AlertTriangle, Loader2, Bell, Printer, Sparkles, Download } from "lucide-react"; import { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -34,6 +35,7 @@ type SortDir = "asc" | "desc"; export default function BillApprovalsPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) { const { toast } = useToast(); + const { isAdmin } = useAuth(); const isBoardView = !!boardAssociationIds?.length; const navigate = useNavigate(); const [bills, setBills] = useState([]); @@ -911,16 +913,18 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci - + {isAdmin && ( + + )} diff --git a/src/pages/BillDetailPage.tsx b/src/pages/BillDetailPage.tsx index dbca1de..032d20c 100644 --- a/src/pages/BillDetailPage.tsx +++ b/src/pages/BillDetailPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/contexts/AuthContext"; import { ArrowLeft, Building2, CreditCard, Calendar, DollarSign, FileText, Download, ChevronLeft, ChevronRight, Send, Save, Plus, @@ -31,6 +32,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { toast } = useToast(); + const { isAdmin } = useAuth(); const [bill, setBill] = useState(null); const [approvals, setApprovals] = useState([]); @@ -355,12 +357,12 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati Request Approval )} - {(bill.status === "pending" || bill.status === "approved") && ( + {isAdmin && (bill.status === "pending" || bill.status === "approved") && ( )} - {bill.status === "paid" && ( + {isAdmin && bill.status === "paid" && ( diff --git a/supabase/migrations/20260607160000_only_admins_mark_bills_paid.sql b/supabase/migrations/20260607160000_only_admins_mark_bills_paid.sql new file mode 100644 index 0000000..e383bca --- /dev/null +++ b/supabase/migrations/20260607160000_only_admins_mark_bills_paid.sql @@ -0,0 +1,34 @@ +-- Restrict the "paid" transition on bills to administrators only. +-- +-- Background: bills RLS lets admin, manager, association_management, and +-- accounting staff update bills, so any of them could mark a bill paid. Per +-- requirement, only admins may do that. This trigger enforces it at the data +-- layer regardless of how the write arrives (UI, API, direct SQL). +-- +-- System/automation contexts (buildium-sync, accounting triggers, autopay) +-- run with the service role, where auth.uid() is NULL — those remain allowed +-- so imports and back-syncs continue to work. + +create or replace function public.enforce_admin_marks_bill_paid() +returns trigger +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if NEW.status = 'paid' + and (TG_OP = 'INSERT' or OLD.status is distinct from 'paid') then + if auth.uid() is not null + and not public.has_role(auth.uid(), 'admin'::app_role) then + raise exception 'Only administrators can mark a bill as paid' + using errcode = 'check_violation'; + end if; + end if; + return NEW; +end; +$$; + +drop trigger if exists trg_bills_admin_paid_guard on public.bills; +create trigger trg_bills_admin_paid_guard + before insert or update on public.bills + for each row execute function public.enforce_admin_marks_bill_paid();