mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Bill approvals: admins-only mark-paid + DB guard
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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
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 { 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 { downloadChecksPdf, type CheckData } from "@/utils/checkPdfGenerator";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
@@ -34,6 +35,7 @@ type SortDir = "asc" | "desc";
|
|||||||
|
|
||||||
export default function BillApprovalsPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
|
export default function BillApprovalsPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
const isBoardView = !!boardAssociationIds?.length;
|
const isBoardView = !!boardAssociationIds?.length;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [bills, setBills] = useState<any[]>([]);
|
const [bills, setBills] = useState<any[]>([]);
|
||||||
@@ -911,16 +913,18 @@ export default function BillApprovalsPage({ boardAssociationIds }: { boardAssoci
|
|||||||
<Button onClick={openNotifyBoard} variant="outline" size="sm" className="gap-2">
|
<Button onClick={openNotifyBoard} variant="outline" size="sm" className="gap-2">
|
||||||
<Bell className="w-4 h-4" /> Notify Board
|
<Bell className="w-4 h-4" /> Notify Board
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{isAdmin && (
|
||||||
onClick={openPrintDialog}
|
<Button
|
||||||
disabled={selectedBillIds.size === 0 || printing}
|
onClick={openPrintDialog}
|
||||||
variant="outline"
|
disabled={selectedBillIds.size === 0 || printing}
|
||||||
size="sm"
|
variant="outline"
|
||||||
className="gap-2"
|
size="sm"
|
||||||
>
|
className="gap-2"
|
||||||
{printing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Printer className="w-4 h-4" />}
|
>
|
||||||
Print Checks ({selectedBillIds.size})
|
{printing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Printer className="w-4 h-4" />}
|
||||||
</Button>
|
Print Checks ({selectedBillIds.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" className="gap-2" onClick={() => setBuildiumOpen(true)}>
|
<Button variant="outline" className="gap-2" onClick={() => setBuildiumOpen(true)}>
|
||||||
<Download className="h-4 w-4" /> Import from Buildium
|
<Download className="h-4 w-4" /> Import from Buildium
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Building2, CreditCard, Calendar, DollarSign,
|
ArrowLeft, Building2, CreditCard, Calendar, DollarSign,
|
||||||
FileText, Download, ChevronLeft, ChevronRight, Send, Save, Plus,
|
FileText, Download, ChevronLeft, ChevronRight, Send, Save, Plus,
|
||||||
@@ -31,6 +32,7 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
const [bill, setBill] = useState<any>(null);
|
const [bill, setBill] = useState<any>(null);
|
||||||
const [approvals, setApprovals] = useState<any[]>([]);
|
const [approvals, setApprovals] = useState<any[]>([]);
|
||||||
@@ -355,12 +357,12 @@ export default function BillDetailPage({ boardAssociationIds }: { boardAssociati
|
|||||||
<CheckCircle className="h-4 w-4" /> Request Approval
|
<CheckCircle className="h-4 w-4" /> Request Approval
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(bill.status === "pending" || bill.status === "approved") && (
|
{isAdmin && (bill.status === "pending" || bill.status === "approved") && (
|
||||||
<Button onClick={() => updateBillStatus("paid")} className="gap-2">
|
<Button onClick={() => updateBillStatus("paid")} className="gap-2">
|
||||||
<CheckCircle className="h-4 w-4" /> Mark Paid
|
<CheckCircle className="h-4 w-4" /> Mark Paid
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{bill.status === "paid" && (
|
{isAdmin && bill.status === "paid" && (
|
||||||
<Button variant="outline" onClick={() => updateBillStatus("approved")} className="gap-2">
|
<Button variant="outline" onClick={() => updateBillStatus("approved")} className="gap-2">
|
||||||
<XCircle className="h-4 w-4" /> Mark Unpaid
|
<XCircle className="h-4 w-4" /> Mark Unpaid
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user