Board-member upload permission for documents & bids/quotes

Add a "Allow document & bid/quote uploads" toggle on board member profiles
(board_members.can_upload). When enabled, that board member can upload
association documents and create/manage bids & quotes for their association(s);
otherwise the board portal stays read-only for them.

- Migration (prod): board_members.can_upload column; tighten the documents
  insert + storage 'files' upload policies to require can_upload; add a
  bids_quotes board policy gated on can_upload.
- BoardMembersPage: permission switch (load/save).
- BoardAssociationContext: expose canUpload for the selected association.
- DocumentsPage: board upload gated by the flag (was always-on for board).
- BidsQuotesPage: permitted board members can add/manage bids (was hidden);
  board inserts target the board's association.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:33:09 -04:00
parent 2fd311c8a2
commit a3a0b706a1
7 changed files with 105 additions and 21 deletions
+26 -3
View File
@@ -14,6 +14,10 @@ interface BoardAssociationContextType {
/** For backward compat: returns array with just the selected ID, or all if none selected */
associationIds: string[];
loading: boolean;
/** Whether this board member may upload docs / bids for the selected association. */
canUpload: boolean;
/** Per-association upload permission map. */
canUploadByAssociation: Record<string, boolean>;
}
const BoardAssociationContext = createContext<BoardAssociationContextType>({
@@ -22,18 +26,22 @@ const BoardAssociationContext = createContext<BoardAssociationContextType>({
setSelectedAssociationId: () => {},
associationIds: [],
loading: true,
canUpload: false,
canUploadByAssociation: {},
});
export function BoardAssociationProvider({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const [associations, setAssociations] = useState<BoardAssociation[]>([]);
const [selectedAssociationId, setSelectedAssociationId] = useState<string | null>(null);
const [uploadByAssoc, setUploadByAssoc] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!user) {
setAssociations([]);
setSelectedAssociationId(null);
setUploadByAssoc({});
setLoading(false);
return;
}
@@ -45,12 +53,12 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
const [bmByIdRes, bmByEmailRes, mbaRes] = await Promise.all([
supabase
.from("board_members")
.select("association_id, associations(name)")
.select("association_id, associations(name), can_upload")
.eq("user_id", user.id),
email
? supabase
.from("board_members")
.select("association_id, associations(name), email")
.select("association_id, associations(name), email, can_upload")
.ilike("email", email)
: Promise.resolve({ data: [] as any[] }),
(supabase as any)
@@ -76,6 +84,18 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
const items = Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name));
setAssociations(items);
// Per-association upload permission (board_members.can_upload). Master-board
// assignments are elevated and always permitted.
const uploadMap: Record<string, boolean> = {};
for (const r of [...((bmByIdRes as any).data || []), ...((bmByEmailRes as any).data || [])]) {
if (!r?.association_id) continue;
uploadMap[r.association_id] = uploadMap[r.association_id] || !!r.can_upload;
}
for (const r of ((mbaRes as any).data || [])) {
if (r?.association_id) uploadMap[r.association_id] = true;
}
setUploadByAssoc(uploadMap);
const savedId = localStorage.getItem("board_selected_association");
if (savedId && items.some((a) => a.id === savedId)) {
setSelectedAssociationId(savedId);
@@ -95,10 +115,13 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
// Filter to just the selected association
const associationIds = selectedAssociationId ? [selectedAssociationId] : associations.map((a) => a.id);
const canUpload = selectedAssociationId
? !!uploadByAssoc[selectedAssociationId]
: Object.values(uploadByAssoc).some(Boolean);
return (
<BoardAssociationContext.Provider
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading }}
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading, canUpload, canUploadByAssociation: uploadByAssoc }}
>
{children}
</BoardAssociationContext.Provider>
+17 -7
View File
@@ -17,7 +17,7 @@ import VotingAndComments from "@/components/shared/VotingAndComments";
const statusColors: Record<string, string> = { pending: "bg-amber-100 text-amber-700", accepted: "bg-emerald-100 text-emerald-700", rejected: "bg-red-100 text-red-700", expired: "bg-muted text-muted-foreground" };
export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociationIds?: string[] } = {}) {
export default function BidsQuotesPage({ boardAssociationIds, boardCanManage = false }: { boardAssociationIds?: string[]; boardCanManage?: boolean } = {}) {
const { toast } = useToast();
const [bids, setBids] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
@@ -28,6 +28,8 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
const [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" });
const isBoardView = !!boardAssociationIds?.length;
// Staff always manage; board members only when granted the upload permission.
const canManage = !isBoardView || boardCanManage;
const fetchData = async () => { setLoading(true); let query = supabase.from("bids_quotes").select("*, associations(name)").order("created_at", { ascending: false }); if (isBoardView) query = query.in("association_id", boardAssociationIds!); const { data } = await query; setBids(data || []); setLoading(false); };
useEffect(() => { fetchData(); }, []);
const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); setDialogOpen(true); };
@@ -35,7 +37,14 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
const handleSave = async () => {
const payload = { ...form, amount: parseFloat(form.amount) || 0, received_date: form.received_date || null, expiry_date: form.expiry_date || null };
if (editing) { await supabase.from("bids_quotes").update(payload).eq("id", editing.id); toast({ title: "Updated" }); }
else { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); if (!a?.length) return; await supabase.from("bids_quotes").insert({ ...payload, association_id: a[0].id }); toast({ title: "Bid created" }); }
else {
let targetAssoc = boardAssociationIds?.[0];
if (!targetAssoc) { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); targetAssoc = a?.[0]?.id; }
if (!targetAssoc) return;
const { error } = await supabase.from("bids_quotes").insert({ ...payload, association_id: targetAssoc });
if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; }
toast({ title: "Bid created" });
}
setDialogOpen(false); fetchData();
};
const handleDelete = async (id: string) => { await supabase.from("bids_quotes").delete().eq("id", id); toast({ title: "Deleted" }); fetchData(); };
@@ -45,7 +54,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div><h1 className="text-2xl font-bold text-foreground flex items-center gap-2"><Scale className="h-6 w-6 text-primary" /> Bids & Quotes</h1><p className="text-sm text-muted-foreground mt-1">Manage vendor bids and quotes.</p></div>
{!isBoardView && <div className="flex gap-2">
{canManage && <div className="flex gap-2">
<RecordImportButton
title="Import Bids & Quotes"
description="Upload a CSV or Excel file with bid/quote records."
@@ -58,8 +67,9 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
{ key: "expiry_date", label: "Expiry Date" },
]}
onImport={async (rows) => {
const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1);
if (!a?.length) throw new Error("Create an association first");
let targetAssoc = boardAssociationIds?.[0];
if (!targetAssoc) { const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1); targetAssoc = a?.[0]?.id; }
if (!targetAssoc) throw new Error("Create an association first");
const payload = rows.map(r => ({
vendor_name: r.vendor_name || "Unknown",
amount: parseFloat(r.amount) || 0,
@@ -67,7 +77,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
status: r.status || "pending",
received_date: r.received_date || null,
expiry_date: r.expiry_date || null,
association_id: a[0].id,
association_id: targetAssoc,
}));
const { error } = await supabase.from("bids_quotes").insert(payload);
if (error) throw error;
@@ -82,7 +92,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
<div className="relative max-w-md"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search..." className="pl-10" value={search} onChange={(e) => setSearch(e.target.value)} /></div>
{loading ? <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div> : filtered.length === 0 ? <Card><CardContent className="py-12 text-center text-muted-foreground">No bids found.</CardContent></Card> : (
<Card><Table><TableHeader><TableRow><TableHead>Vendor</TableHead><TableHead>Amount</TableHead><TableHead>Status</TableHead><TableHead>Received</TableHead><TableHead>Expires</TableHead><TableHead className="w-12" /></TableRow></TableHeader><TableBody>
{filtered.map((b) => (<TableRow key={b.id} className="cursor-pointer" onClick={() => setSelectedBid(b)}><TableCell className="font-medium">{b.vendor_name}</TableCell><TableCell>${Number(b.amount).toLocaleString()}</TableCell><TableCell><Badge className={statusColors[b.status] || "bg-muted text-muted-foreground"}>{b.status}</Badge></TableCell><TableCell>{b.received_date || "—"}</TableCell><TableCell>{b.expiry_date || "—"}</TableCell>{!isBoardView && <TableCell>
{filtered.map((b) => (<TableRow key={b.id} className="cursor-pointer" onClick={() => setSelectedBid(b)}><TableCell className="font-medium">{b.vendor_name}</TableCell><TableCell>${Number(b.amount).toLocaleString()}</TableCell><TableCell><Badge className={statusColors[b.status] || "bg-muted text-muted-foreground"}>{b.status}</Badge></TableCell><TableCell>{b.received_date || "—"}</TableCell><TableCell>{b.expiry_date || "—"}</TableCell>{canManage && <TableCell>
<DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end"><DropdownMenuItem onClick={(e) => { e.stopPropagation(); openEdit(b); }}><Edit className="h-4 w-4 mr-2" /> Edit</DropdownMenuItem><DropdownMenuItem className="text-destructive" onClick={(e) => { e.stopPropagation(); handleDelete(b.id); }}><Trash2 className="h-4 w-4 mr-2" /> Delete</DropdownMenuItem></DropdownMenuContent></DropdownMenu></TableCell>}</TableRow>))}</TableBody></Table></Card>
)}
+10 -5
View File
@@ -16,11 +16,11 @@ import type { Tables } from "@/integrations/supabase/types";
type BoardMember = {
id: string; association_id: string; member_name: string; member_email: string | null;
phone: string | null; role: string | null; approval_authority: boolean;
phone: string | null; role: string | null; approval_authority: boolean; can_upload: boolean;
created_at: string; associations?: { name: string } | null;
};
const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, association_id: "" };
const emptyForm = { member_name: "", member_email: "", phone: "", role: "Member", approval_authority: false, can_upload: false, association_id: "" };
export default function BoardMembersPage() {
const { toast } = useToast();
@@ -55,7 +55,7 @@ export default function BoardMembersPage() {
const openAdd = () => { setEditingId(null); setForm({ ...emptyForm }); setFormOpen(true); };
const openEdit = (m: BoardMember) => {
setEditingId(m.id);
setForm({ member_name: m.member_name, member_email: m.member_email || "", phone: m.phone || "", role: m.role || "Member", approval_authority: m.approval_authority, association_id: m.association_id });
setForm({ member_name: m.member_name, member_email: m.member_email || "", phone: m.phone || "", role: m.role || "Member", approval_authority: m.approval_authority, can_upload: m.can_upload ?? false, association_id: m.association_id });
setFormOpen(true);
};
@@ -68,10 +68,11 @@ export default function BoardMembersPage() {
association_id: form.association_id, member_name: form.member_name,
member_email: form.member_email || null, phone: form.phone || null,
role: form.role || "Member", approval_authority: form.approval_authority,
can_upload: form.can_upload,
};
const { error } = editingId
? await supabase.from("board_members").update(payload).eq("id", editingId)
: await supabase.from("board_members").insert(payload);
? await supabase.from("board_members").update(payload as any).eq("id", editingId)
: await supabase.from("board_members").insert(payload as any);
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
else { toast({ title: editingId ? "Member updated" : "Member added" }); setFormOpen(false); fetchData(); }
setSaving(false);
@@ -208,6 +209,10 @@ export default function BoardMembersPage() {
<Switch checked={form.approval_authority} onCheckedChange={v => setForm({ ...form, approval_authority: v })} />
<Label>Approval Authority</Label>
</div>
<div className="flex items-center gap-3">
<Switch checked={form.can_upload} onCheckedChange={v => setForm({ ...form, can_upload: v })} />
<Label>Allow document & bid/quote uploads</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button>
+6 -2
View File
@@ -236,9 +236,12 @@ function FolderSidebar({
export default function DocumentsPage({
boardAssociationIds,
viewerRole = "admin",
boardCanUpload = false,
}: {
boardAssociationIds?: string[];
viewerRole?: "admin" | "board" | "homeowner";
/** For board viewers: whether this board member is permitted to upload. */
boardCanUpload?: boolean;
} = {}) {
const { toast } = useToast();
const { isAdmin } = useAuth();
@@ -372,8 +375,9 @@ export default function DocumentsPage({
const isBoardView = viewerRole === "board" || (!!boardAssociationIds?.length && viewerRole !== "homeowner");
const isHomeownerView = viewerRole === "homeowner";
const isReadOnlyView = isBoardView || isHomeownerView;
// Board members can upload documents within their association(s); homeowners remain read-only.
const canUpload = !isHomeownerView;
// Board members can upload only when granted the per-member permission;
// homeowners are always read-only; staff can always upload.
const canUpload = isHomeownerView ? false : isBoardView ? boardCanUpload : true;
const getFolderAssociation = (folder: string | null) => {
if (!folder) return null;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
import BidsQuotesPage from "@/pages/BidsQuotesPage";
export default function BoardBidsQuotesPage() {
const { associationIds, loading } = useBoardAssociations();
const { associationIds, loading, canUpload } = useBoardAssociations();
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
return <BidsQuotesPage boardAssociationIds={associationIds} />;
return <BidsQuotesPage boardAssociationIds={associationIds} boardCanManage={canUpload} />;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
import DocumentsPage from "@/pages/DocumentsPage";
export default function BoardDocumentsPage() {
const { associationIds, loading } = useBoardAssociations();
const { associationIds, loading, canUpload } = useBoardAssociations();
if (loading) return <div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>;
return <DocumentsPage boardAssociationIds={associationIds} viewerRole="board" />;
return <DocumentsPage boardAssociationIds={associationIds} viewerRole="board" boardCanUpload={canUpload} />;
}