diff --git a/src/contexts/BoardAssociationContext.tsx b/src/contexts/BoardAssociationContext.tsx index 6db85e6..5d10ed9 100644 --- a/src/contexts/BoardAssociationContext.tsx +++ b/src/contexts/BoardAssociationContext.tsx @@ -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; } const BoardAssociationContext = createContext({ @@ -22,18 +26,22 @@ const BoardAssociationContext = createContext({ setSelectedAssociationId: () => {}, associationIds: [], loading: true, + canUpload: false, + canUploadByAssociation: {}, }); export function BoardAssociationProvider({ children }: { children: React.ReactNode }) { const { user } = useAuth(); const [associations, setAssociations] = useState([]); const [selectedAssociationId, setSelectedAssociationId] = useState(null); + const [uploadByAssoc, setUploadByAssoc] = useState>({}); 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 = {}; + 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 ( {children} diff --git a/src/pages/BidsQuotesPage.tsx b/src/pages/BidsQuotesPage.tsx index a4d8098..8ccca35 100644 --- a/src/pages/BidsQuotesPage.tsx +++ b/src/pages/BidsQuotesPage.tsx @@ -17,7 +17,7 @@ import VotingAndComments from "@/components/shared/VotingAndComments"; const statusColors: Record = { 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([]); 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

Bids & Quotes

Manage vendor bids and quotes.

- {!isBoardView &&
+ {canManage &&
{ - 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
setSearch(e.target.value)} />
{loading ?
: filtered.length === 0 ? No bids found. : ( VendorAmountStatusReceivedExpires - {filtered.map((b) => ( setSelectedBid(b)}>{b.vendor_name}${Number(b.amount).toLocaleString()}{b.status}{b.received_date || "—"}{b.expiry_date || "—"}{!isBoardView && + {filtered.map((b) => ( setSelectedBid(b)}>{b.vendor_name}${Number(b.amount).toLocaleString()}{b.status}{b.received_date || "—"}{b.expiry_date || "—"}{canManage && { e.stopPropagation(); openEdit(b); }}> Edit { e.stopPropagation(); handleDelete(b.id); }}> Delete}))}
)} diff --git a/src/pages/BoardMembersPage.tsx b/src/pages/BoardMembersPage.tsx index 8584f2c..3f98009 100644 --- a/src/pages/BoardMembersPage.tsx +++ b/src/pages/BoardMembersPage.tsx @@ -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() { setForm({ ...form, approval_authority: v })} />
+
+ setForm({ ...form, can_upload: v })} /> + +
diff --git a/src/pages/DocumentsPage.tsx b/src/pages/DocumentsPage.tsx index 43d88f5..de9f0db 100644 --- a/src/pages/DocumentsPage.tsx +++ b/src/pages/DocumentsPage.tsx @@ -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; diff --git a/src/pages/board/BoardBidsQuotesPage.tsx b/src/pages/board/BoardBidsQuotesPage.tsx index 2de6627..a77cd59 100644 --- a/src/pages/board/BoardBidsQuotesPage.tsx +++ b/src/pages/board/BoardBidsQuotesPage.tsx @@ -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
; - return ; + return ; } diff --git a/src/pages/board/BoardDocumentsPage.tsx b/src/pages/board/BoardDocumentsPage.tsx index 3100d98..13a17ab 100644 --- a/src/pages/board/BoardDocumentsPage.tsx +++ b/src/pages/board/BoardDocumentsPage.tsx @@ -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
; - return ; + return ; } diff --git a/supabase/migrations/20260601170000_board_member_upload_permission.sql b/supabase/migrations/20260601170000_board_member_upload_permission.sql new file mode 100644 index 0000000..02516c0 --- /dev/null +++ b/supabase/migrations/20260601170000_board_member_upload_permission.sql @@ -0,0 +1,42 @@ +-- Per-board-member "can upload" permission. When enabled, that board member may +-- upload association documents (files bucket + documents table) and create/manage +-- bids & quotes for their association(s). Default off. + +alter table public.board_members + add column if not exists can_upload boolean not null default false; + +-- Documents: tighten the existing board insert policy to require the flag. +alter policy "Board members can insert association documents" on public.documents + with check ( + association_id in ( + select bm.association_id from public.board_members bm + where bm.user_id = auth.uid() and bm.can_upload + ) + ); + +-- Storage (files bucket): same gate on the board upload policy. +alter policy "Board members can upload association files" on storage.objects + with check ( + bucket_id = 'files' + and ((storage.foldername(name))[1])::uuid in ( + select bm.association_id from public.board_members bm + where bm.user_id = auth.uid() and bm.can_upload + ) + ); + +-- Bids & Quotes: allow permitted board members to manage their association's bids. +drop policy if exists "Board members manage association bids" on public.bids_quotes; +create policy "Board members manage association bids" on public.bids_quotes + for all + using ( + association_id in ( + select bm.association_id from public.board_members bm + where bm.user_id = auth.uid() and bm.can_upload + ) + ) + with check ( + association_id in ( + select bm.association_id from public.board_members bm + where bm.user_id = auth.uid() and bm.can_upload + ) + );