diff --git a/src/actions/ai-import.ts b/src/actions/ai-import.ts index ea30d5c..c276886 100644 --- a/src/actions/ai-import.ts +++ b/src/actions/ai-import.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { assertBrandAccess } from "@/lib/brand-scope"; import { parseExcelBuffer, parseTextBuffer } from "@/lib/excel-parser"; import { importProductsBatch } from "@/actions/import-products"; import { importOrdersBatch } from "@/actions/import-orders"; @@ -41,7 +42,9 @@ export async function analyzeImport( ): Promise { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - if (adminUser.role === "brand_admin" && adminUser.brand_id !== brandId) { + try { + assertBrandAccess(adminUser, brandId); + } catch { return { success: false, error: "Not authorized for this brand" }; } diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts index b656b07..1fd2dd2 100644 --- a/src/actions/analytics.ts +++ b/src/actions/analytics.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; import { svcHeaders } from "@/lib/svc-headers"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; @@ -96,7 +97,7 @@ async function brandScopedRPC( const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); - const brandId = adminUser.brand_id ?? null; + const brandId = await getActiveBrandId(adminUser); const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, { method: "POST", @@ -232,7 +233,7 @@ export async function getRecentOrders(limit: number = 10): Promise { const adminUser = await getAdminUser(); if (!adminUser) throw new Error("Not authenticated"); - const brandId = adminUser.brand_id ?? null; + const brandId = await getActiveBrandId(adminUser); const params = new URLSearchParams({ select: "id,status", diff --git a/src/actions/brands.ts b/src/actions/brands.ts new file mode 100644 index 0000000..fdd5866 --- /dev/null +++ b/src/actions/brands.ts @@ -0,0 +1,65 @@ +import "server-only"; + +import { getAdminUser } from "@/lib/admin-permissions"; +import { svcHeaders } from "@/lib/svc-headers"; + +export type BrandListItem = { + id: string; + name: string; + slug: string; + logo_url: string | null; +}; + +/** + * Returns the list of brands the current admin user can act in. + * + * - platform_admin: all brands (queried directly from the `brands` table) + * - everyone else: brands in `adminUser.brand_ids` + * - empty array for unauthenticated / no-access admins + * + * This is a plain async function (not a server action) so it can be called + * from server components and server actions without the "use server" wrapper. + * The BrandSelector client component receives the result as a prop. + */ +export async function listBrandsForAdmin(): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return []; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !serviceKey) return []; + + if (adminUser.role === "platform_admin") { + try { + const res = await fetch( + `${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`, + { headers: svcHeaders(serviceKey), cache: "no-store" } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } + } + + if (adminUser.brand_ids.length === 0) return []; + + // Use PostgREST `in` filter. The brand_ids are UUIDs, so the quoting + // pattern in the spec is safe; the inner quotes are required by PostgREST + // for UUID literals. + const filter = `id=in.(${adminUser.brand_ids + .map((id) => `"${id}"`) + .join(",")})`; + try { + const res = await fetch( + `${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`, + { headers: svcHeaders(serviceKey), cache: "no-store" } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} diff --git a/src/actions/communications/campaigns.ts b/src/actions/communications/campaigns.ts index d752956..2a5c64d 100644 --- a/src/actions/communications/campaigns.ts +++ b/src/actions/communications/campaigns.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; import { svcHeaders } from "@/lib/svc-headers"; export type CampaignType = "marketing" | "operational" | "transactional"; @@ -57,7 +58,11 @@ export async function getCommunicationCampaigns(brandId?: string): Promise cookie > legacy > first of brand_ids) + const activeBrandId = await getActiveBrandId(adminUser, brandId); + if (!activeBrandId && adminUser.role !== "platform_admin") { + return { success: false, error: "Brand access required" }; + } + const effectiveBrandId = activeBrandId; // Brand scoping: brand_admin can only send their own brand's campaigns - if (adminUser.role === "brand_admin" && effectiveBrandId && adminUser.brand_id !== effectiveBrandId) { + if (adminUser.role === "brand_admin" && effectiveBrandId && !adminUser.brand_ids.includes(effectiveBrandId)) { return { success: false, error: "Not authorized to send this brand's campaigns" }; } diff --git a/src/actions/communications/templates.ts b/src/actions/communications/templates.ts index 0ee0651..98907fa 100644 --- a/src/actions/communications/templates.ts +++ b/src/actions/communications/templates.ts @@ -1,6 +1,7 @@ "use server"; import { getAdminUser } from "@/lib/admin-permissions"; +import { getActiveBrandId } from "@/lib/brand-scope"; import { svcHeaders } from "@/lib/svc-headers"; import type { AudienceRules } from "./campaigns"; @@ -33,7 +34,11 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - const effectiveBrandId = brandId ?? adminUser.brand_id ?? null; + const activeBrandId = await getActiveBrandId(adminUser, brandId); + if (!activeBrandId && adminUser.role !== "platform_admin") { + return { success: false, error: "Brand access required" }; + } + const effectiveBrandId = activeBrandId; // Brand scoping: brand_admin can only see their own brand's templates if (adminUser.role === "brand_admin" && effectiveBrandId && effectiveBrandId !== adminUser.brand_id) { @@ -112,7 +117,7 @@ export async function getTemplateById(templateId: string): Promise