mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -14,6 +14,10 @@ interface BoardAssociationContextType {
|
|||||||
/** For backward compat: returns array with just the selected ID, or all if none selected */
|
/** For backward compat: returns array with just the selected ID, or all if none selected */
|
||||||
associationIds: string[];
|
associationIds: string[];
|
||||||
loading: boolean;
|
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>({
|
const BoardAssociationContext = createContext<BoardAssociationContextType>({
|
||||||
@@ -22,18 +26,22 @@ const BoardAssociationContext = createContext<BoardAssociationContextType>({
|
|||||||
setSelectedAssociationId: () => {},
|
setSelectedAssociationId: () => {},
|
||||||
associationIds: [],
|
associationIds: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
|
canUpload: false,
|
||||||
|
canUploadByAssociation: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function BoardAssociationProvider({ children }: { children: React.ReactNode }) {
|
export function BoardAssociationProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [associations, setAssociations] = useState<BoardAssociation[]>([]);
|
const [associations, setAssociations] = useState<BoardAssociation[]>([]);
|
||||||
const [selectedAssociationId, setSelectedAssociationId] = useState<string | null>(null);
|
const [selectedAssociationId, setSelectedAssociationId] = useState<string | null>(null);
|
||||||
|
const [uploadByAssoc, setUploadByAssoc] = useState<Record<string, boolean>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setAssociations([]);
|
setAssociations([]);
|
||||||
setSelectedAssociationId(null);
|
setSelectedAssociationId(null);
|
||||||
|
setUploadByAssoc({});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -45,12 +53,12 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
|
|||||||
const [bmByIdRes, bmByEmailRes, mbaRes] = await Promise.all([
|
const [bmByIdRes, bmByEmailRes, mbaRes] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from("board_members")
|
.from("board_members")
|
||||||
.select("association_id, associations(name)")
|
.select("association_id, associations(name), can_upload")
|
||||||
.eq("user_id", user.id),
|
.eq("user_id", user.id),
|
||||||
email
|
email
|
||||||
? supabase
|
? supabase
|
||||||
.from("board_members")
|
.from("board_members")
|
||||||
.select("association_id, associations(name), email")
|
.select("association_id, associations(name), email, can_upload")
|
||||||
.ilike("email", email)
|
.ilike("email", email)
|
||||||
: Promise.resolve({ data: [] as any[] }),
|
: Promise.resolve({ data: [] as any[] }),
|
||||||
(supabase 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));
|
const items = Array.from(merged.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setAssociations(items);
|
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");
|
const savedId = localStorage.getItem("board_selected_association");
|
||||||
if (savedId && items.some((a) => a.id === savedId)) {
|
if (savedId && items.some((a) => a.id === savedId)) {
|
||||||
setSelectedAssociationId(savedId);
|
setSelectedAssociationId(savedId);
|
||||||
@@ -95,10 +115,13 @@ export function BoardAssociationProvider({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
// Filter to just the selected association
|
// Filter to just the selected association
|
||||||
const associationIds = selectedAssociationId ? [selectedAssociationId] : associations.map((a) => a.id);
|
const associationIds = selectedAssociationId ? [selectedAssociationId] : associations.map((a) => a.id);
|
||||||
|
const canUpload = selectedAssociationId
|
||||||
|
? !!uploadByAssoc[selectedAssociationId]
|
||||||
|
: Object.values(uploadByAssoc).some(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BoardAssociationContext.Provider
|
<BoardAssociationContext.Provider
|
||||||
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading }}
|
value={{ associations, selectedAssociationId, setSelectedAssociationId: handleSelect, associationIds, loading, canUpload, canUploadByAssociation: uploadByAssoc }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BoardAssociationContext.Provider>
|
</BoardAssociationContext.Provider>
|
||||||
|
|||||||
@@ -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" };
|
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 { toast } = useToast();
|
||||||
const [bids, setBids] = useState<any[]>([]);
|
const [bids, setBids] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [form, setForm] = useState({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" });
|
||||||
|
|
||||||
const isBoardView = !!boardAssociationIds?.length;
|
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); };
|
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(); }, []);
|
useEffect(() => { fetchData(); }, []);
|
||||||
const openNew = () => { setEditing(null); setForm({ vendor_name: "", amount: "", description: "", status: "pending", received_date: "", expiry_date: "" }); setDialogOpen(true); };
|
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 handleSave = async () => {
|
||||||
const payload = { ...form, amount: parseFloat(form.amount) || 0, received_date: form.received_date || null, expiry_date: form.expiry_date || null };
|
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" }); }
|
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();
|
setDialogOpen(false); fetchData();
|
||||||
};
|
};
|
||||||
const handleDelete = async (id: string) => { await supabase.from("bids_quotes").delete().eq("id", id); toast({ title: "Deleted" }); 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="space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<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>
|
<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
|
<RecordImportButton
|
||||||
title="Import Bids & Quotes"
|
title="Import Bids & Quotes"
|
||||||
description="Upload a CSV or Excel file with bid/quote records."
|
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" },
|
{ key: "expiry_date", label: "Expiry Date" },
|
||||||
]}
|
]}
|
||||||
onImport={async (rows) => {
|
onImport={async (rows) => {
|
||||||
const { data: a } = await supabase.from("associations").select("id").eq("status", "active").limit(1);
|
let targetAssoc = boardAssociationIds?.[0];
|
||||||
if (!a?.length) throw new Error("Create an association first");
|
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 => ({
|
const payload = rows.map(r => ({
|
||||||
vendor_name: r.vendor_name || "Unknown",
|
vendor_name: r.vendor_name || "Unknown",
|
||||||
amount: parseFloat(r.amount) || 0,
|
amount: parseFloat(r.amount) || 0,
|
||||||
@@ -67,7 +77,7 @@ export default function BidsQuotesPage({ boardAssociationIds }: { boardAssociati
|
|||||||
status: r.status || "pending",
|
status: r.status || "pending",
|
||||||
received_date: r.received_date || null,
|
received_date: r.received_date || null,
|
||||||
expiry_date: r.expiry_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);
|
const { error } = await supabase.from("bids_quotes").insert(payload);
|
||||||
if (error) throw error;
|
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>
|
<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> : (
|
{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>
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import type { Tables } from "@/integrations/supabase/types";
|
|||||||
|
|
||||||
type BoardMember = {
|
type BoardMember = {
|
||||||
id: string; association_id: string; member_name: string; member_email: string | null;
|
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;
|
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() {
|
export default function BoardMembersPage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -55,7 +55,7 @@ export default function BoardMembersPage() {
|
|||||||
const openAdd = () => { setEditingId(null); setForm({ ...emptyForm }); setFormOpen(true); };
|
const openAdd = () => { setEditingId(null); setForm({ ...emptyForm }); setFormOpen(true); };
|
||||||
const openEdit = (m: BoardMember) => {
|
const openEdit = (m: BoardMember) => {
|
||||||
setEditingId(m.id);
|
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);
|
setFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,10 +68,11 @@ export default function BoardMembersPage() {
|
|||||||
association_id: form.association_id, member_name: form.member_name,
|
association_id: form.association_id, member_name: form.member_name,
|
||||||
member_email: form.member_email || null, phone: form.phone || null,
|
member_email: form.member_email || null, phone: form.phone || null,
|
||||||
role: form.role || "Member", approval_authority: form.approval_authority,
|
role: form.role || "Member", approval_authority: form.approval_authority,
|
||||||
|
can_upload: form.can_upload,
|
||||||
};
|
};
|
||||||
const { error } = editingId
|
const { error } = editingId
|
||||||
? await supabase.from("board_members").update(payload).eq("id", editingId)
|
? await supabase.from("board_members").update(payload as any).eq("id", editingId)
|
||||||
: await supabase.from("board_members").insert(payload);
|
: await supabase.from("board_members").insert(payload as any);
|
||||||
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
|
if (error) toast({ variant: "destructive", title: "Error", description: error.message });
|
||||||
else { toast({ title: editingId ? "Member updated" : "Member added" }); setFormOpen(false); fetchData(); }
|
else { toast({ title: editingId ? "Member updated" : "Member added" }); setFormOpen(false); fetchData(); }
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -208,6 +209,10 @@ export default function BoardMembersPage() {
|
|||||||
<Switch checked={form.approval_authority} onCheckedChange={v => setForm({ ...form, approval_authority: v })} />
|
<Switch checked={form.approval_authority} onCheckedChange={v => setForm({ ...form, approval_authority: v })} />
|
||||||
<Label>Approval Authority</Label>
|
<Label>Approval Authority</Label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button>
|
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancel</Button>
|
||||||
|
|||||||
@@ -236,9 +236,12 @@ function FolderSidebar({
|
|||||||
export default function DocumentsPage({
|
export default function DocumentsPage({
|
||||||
boardAssociationIds,
|
boardAssociationIds,
|
||||||
viewerRole = "admin",
|
viewerRole = "admin",
|
||||||
|
boardCanUpload = false,
|
||||||
}: {
|
}: {
|
||||||
boardAssociationIds?: string[];
|
boardAssociationIds?: string[];
|
||||||
viewerRole?: "admin" | "board" | "homeowner";
|
viewerRole?: "admin" | "board" | "homeowner";
|
||||||
|
/** For board viewers: whether this board member is permitted to upload. */
|
||||||
|
boardCanUpload?: boolean;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
@@ -372,8 +375,9 @@ export default function DocumentsPage({
|
|||||||
const isBoardView = viewerRole === "board" || (!!boardAssociationIds?.length && viewerRole !== "homeowner");
|
const isBoardView = viewerRole === "board" || (!!boardAssociationIds?.length && viewerRole !== "homeowner");
|
||||||
const isHomeownerView = viewerRole === "homeowner";
|
const isHomeownerView = viewerRole === "homeowner";
|
||||||
const isReadOnlyView = isBoardView || isHomeownerView;
|
const isReadOnlyView = isBoardView || isHomeownerView;
|
||||||
// Board members can upload documents within their association(s); homeowners remain read-only.
|
// Board members can upload only when granted the per-member permission;
|
||||||
const canUpload = !isHomeownerView;
|
// homeowners are always read-only; staff can always upload.
|
||||||
|
const canUpload = isHomeownerView ? false : isBoardView ? boardCanUpload : true;
|
||||||
|
|
||||||
const getFolderAssociation = (folder: string | null) => {
|
const getFolderAssociation = (folder: string | null) => {
|
||||||
if (!folder) return null;
|
if (!folder) return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||||
import BidsQuotesPage from "@/pages/BidsQuotesPage";
|
import BidsQuotesPage from "@/pages/BidsQuotesPage";
|
||||||
export default function BoardBidsQuotesPage() {
|
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>;
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
import { useBoardAssociations } from "@/contexts/BoardAssociationContext";
|
||||||
import DocumentsPage from "@/pages/DocumentsPage";
|
import DocumentsPage from "@/pages/DocumentsPage";
|
||||||
export default function BoardDocumentsPage() {
|
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>;
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user