Adds a design document for supporting admins who manage 2+ specific brands (e.g., franchise / multi-brand tenant use cases). Current model: admin_users.brand_id is a single UUID | null, and the effectiveBrandId = brandId ?? adminUser.brand_id ?? null pattern silently does the wrong thing for multi-brand admins. There is no central validation that an admin is acting in a brand they have access to. Proposed: admin_user_brands junction table (m:n), kept admin_users.brand_id for backwards compat, a new multi_brand_admin role, a cookie-based active brand, a centralized brand-scope helper, and a BrandSelector dropdown in the admin header. ~30 server actions and ~10 page server components get a mechanical one-line swap to use the new helper. Follow-up migration 220_drop_legacy_brand_id.sql will drop the legacy column after we verify nothing reads it. Out of scope for this spec. See docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md
19 KiB
Multi-Brand Admin Support
Date: 2026-06-04
Status: Draft → Approved
Author: Grok (brainstorming session)
Migration file: supabase/migrations/204_multi_brand_admin.sql
Follow-up migration (out of scope): 220_drop_legacy_brand_id.sql
Problem
admin_users.brand_id is a single UUID | null. The platform supports a platform_admin role (no brand) and a brand_admin role (one brand). There is no representation for an admin who legitimately needs access to 2+ specific brands — e.g., a parent company operating multiple storefronts with shared operations staff.
The current effectiveBrandId = brandId ?? adminUser.brand_id ?? null pattern silently does the wrong thing for multi-brand use cases:
- Data model can't represent "Jane is admin for Brand A AND Brand B" — would require two
admin_usersrows (and the auth-user→admin-user join gets messy). - No central validation that an admin is acting in a brand they actually have access to.
- No persistent "current brand" context — every page re-derives it from scratch.
- No per-brand permission overrides possible (locks in flexibility for the future).
This spec addresses multi-brand tenants / franchises: same staff managing multiple brands under a parent org, where brands are separate for storefront/billing but share operations.
Goals
- An admin can be associated with multiple brands via a junction table.
- A persistent "active brand" is stored in a cookie, switchable via UI, and used as the default when no explicit brand is requested.
- A new
multi_brand_adminrole makes the relationship explicit in the data and the UI. platform_admincontinues to work unchanged (gets all brands implicitly).- Existing single-brand
brand_admin,store_employee, andstaffusers continue to work with zero behavior change. - Server actions get a single, central place to resolve and validate the active brand.
Non-Goals (YAGNI)
- Per-(admin, brand) permission overrides. The user explicitly chose "same perms across all brands."
- Brand-group / parent-org concept. The junction table makes this possible later, but it's not built now.
- "Last accessed brand" auto-redirect. The cookie is the source of truth; no extra logic.
- UI for managing
admin_user_brandsrows. Use Supabase Studio or a follow-up admin UI PR. - Dropping the legacy
admin_users.brand_idcolumn. A follow-up migration220_drop_legacy_brand_id.sqlwill do this after we verify nothing reads it. Out of scope for this spec.
Approach: Junction Table + Backwards-Compat brand_id
Selected from among three options:
| Option | Why not |
|---|---|
A. Junction + keep brand_id (selected) |
— |
B. Junction only, drop brand_id |
High migration risk; every server action reference must change. |
C. brand_ids UUID[] on admin_users |
No FK, awkward reverse lookups, locks out per-brand metadata. |
A is the lowest-risk additive path that solves the problem.
Data Model
New table: admin_user_brands
CREATE TABLE admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
- Composite PK enforces uniqueness.
- Index on
brand_idmakes "which admins are in Brand X?" queries fast. added_at/added_byfor audit trail.ON DELETE CASCADEfor both FKs — deleting an admin or brand cleans up the junction.
New role: multi_brand_admin
Added to the role CHECK constraint. Functionally equivalent to brand_admin permission-wise — same can_manage_* flags apply. The role label disambiguates intent in the UI ("this person manages 3 brands" vs "this person manages 1 brand") and in audit logs.
Updated AdminUser type
// src/lib/admin-permissions-types.ts
export type AdminUser = {
// ... existing fields
brand_id: string | null; // active brand (one of brand_ids, or null for platform_admin)
brand_ids: string[]; // all brands this admin can act in
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
};
Membership rules
| Role | brand_id (active) |
brand_ids (membership) |
|---|---|---|
platform_admin |
null (or cookie-selected brand) |
Implicitly all brands; brand_ids populated by listBrandsForAdmin querying the brands table |
multi_brand_admin |
First/selected brand | 2+ brands from admin_user_brands |
brand_admin |
Their single brand | [that one brand] |
store_employee |
Their single brand | [that one brand] |
staff |
Their single brand | [that one brand] |
For platform_admin, the application layer short-circuits brand-access checks (if (adminUser.role === "platform_admin") return ...). The brand_ids field is only used by listBrandsForAdmin to render the dropdown options; it is not used for permission gating for platform_admin.
getAdminUser() resolution order
- If
dev_sessioncookie set → return dev admin. Forplatform_admindev:brand_id: null, brand_ids: [](resolved againstbrandstable bylistBrandsForAdmin). Forstore_employeedev:brand_id: <first-real-brand-id>, brand_ids: [<that-id>]— fetched from thebrandstable so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees<AdminAccessDenied />— known limitation.) - If
NEXT_PUBLIC_USE_MOCK_DATA=true→ same asplatform_admindev. - Real auth → load
admin_usersrow, then JOINadmin_user_brandsto populatebrand_ids. - Set
brand_idfrom (in order):active_brand_idcookie (if inbrand_idsfor non-platform-admin, or always for platform-admin) →admin_users.brand_id(if inbrand_ids) → first ofbrand_ids.
Server Action Patterns
New file: src/lib/brand-scope.ts
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
const ACTIVE_BRAND_COOKIE = "active_brand_id";
export async function getActiveBrandId(
adminUser: AdminUser,
requested?: string | null
): Promise<string | null> {
// Cookie is the source of truth for "what brand am I acting in right now"
// for everyone — including platform_admin (who can pin a specific brand
// or fall back to null = "all brands").
const cookieStore = await cookies();
const cookieBrand = cookieStore.get(ACTIVE_BRAND_COOKIE)?.value ?? null;
if (adminUser.role === "platform_admin") {
// requested > cookie > null (all brands)
return requested ?? cookieBrand ?? null;
}
// Non-platform-admin: requested (if in brand_ids) > cookie (if in brand_ids) > adminUser.brand_id
if (requested) {
return adminUser.brand_ids.includes(requested) ? requested : null;
}
if (cookieBrand && adminUser.brand_ids.includes(cookieBrand)) {
return cookieBrand;
}
return adminUser.brand_id;
}
export async function setActiveBrandCookie(brandId: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(ACTIVE_BRAND_COOKIE, brandId, {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 days
});
}
export async function clearActiveBrandCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ACTIVE_BRAND_COOKIE);
}
export function assertBrandAccess(adminUser: AdminUser, brandId: string): void {
if (adminUser.role === "platform_admin") return;
if (!adminUser.brand_ids.includes(brandId)) {
throw new Error("Brand access denied");
}
}
Server action: set active brand
// src/actions/admin/set-active-brand.ts
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { setActiveBrandCookie, clearActiveBrandCookie } from "@/lib/brand-scope";
export async function setActiveBrand(brandId: string | null): Promise<{ success: boolean; error?: string }> {
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// null = "All brands" (platform_admin only)
if (brandId === null) {
if (adminUser.role !== "platform_admin") {
return { success: false, error: "Only platform admins can select 'All brands'" };
}
await clearActiveBrandCookie();
return { success: true };
}
if (adminUser.role !== "platform_admin" && !adminUser.brand_ids.includes(brandId)) {
return { success: false, error: "No access to that brand" };
}
await setActiveBrandCookie(brandId);
return { success: true };
}
New server function: list brands for admin
// src/actions/brands.ts
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
export async function listBrandsForAdmin(): Promise<
{ id: string; name: string; slug: string; logo_url: string | null }[]
> {
const adminUser = await getAdminUser();
if (!adminUser) return [];
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
if (adminUser.role === "platform_admin") {
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey) }
);
return res.ok ? await res.json() : [];
}
if (adminUser.brand_ids.length === 0) return [];
const filter = `id=in.(${adminUser.brand_ids.map((id) => `"${id}"`).join(",")})`;
const res = await fetch(
`${supabaseUrl}/rest/v1/brands?${filter}&select=id,name,slug,logo_url&order=name`,
{ headers: svcHeaders(serviceKey) }
);
return res.ok ? await res.json() : [];
}
Server action migration pattern
// Before:
const effectiveBrandId = brandId ?? adminUser.brand_id ?? null;
// After:
import { getActiveBrandId } from "@/lib/brand-scope";
const activeBrandId = await getActiveBrandId(adminUser, brandId);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return { success: false, error: "Brand access required" };
}
const effectiveBrandId = activeBrandId;
This is a mechanical one-line swap in ~30 server actions identified by the grep:
src/actions/wholesale.ts(multiple)src/actions/products.tssrc/actions/communications/templates.tssrc/actions/communications/campaigns.tssrc/actions/orders/create-admin-order.tssrc/actions/stops.tssrc/actions/analytics.tssrc/actions/square-sync-ui.tssrc/actions/shipping.tssrc/actions/payments.tssrc/actions/ai-import.tssrc/actions/wholesale-register.ts
Page server component pattern
// Before (src/app/admin/orders/page.tsx):
const effectiveBrandId = brandIdParam ?? adminUser.brand_id ?? "";
// After:
import { getActiveBrandId } from "@/lib/brand-scope";
export default async function AdminOrdersPage() {
const adminUser = await getAdminUser();
if (!adminUser) return <AdminAccessDenied />;
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
if (!activeBrandId && adminUser.role !== "platform_admin") {
return <AdminAccessDenied />;
}
// ... rest of the page
}
This applies to every page under src/app/admin/ — including the existing /admin/taxes/[brandId] and /admin/settings/billing/[brandId] which already do brand param resolution.
UI Components
Brand selector
Location: src/components/admin/AdminHeader.tsx (the top bar already rendered on every /admin/* page).
Behavior: A dropdown showing:
- Brand logo + name (current active brand)
- Chevron
- "All brands" option at the top (only for
platform_admin) - List of accessible brands (
adminUser.brand_ids) - Small badge "Multi-brand manager" next to the user's name when
role === "multi_brand_admin"
Visibility matrix:
| Admin | Show dropdown? | Options |
|---|---|---|
platform_admin |
Yes | "All brands" + list of all brands |
multi_brand_admin (2+ brands) |
Yes | List of their brands |
brand_admin / store_employee / staff (1 brand) |
No | — |
platform_admin (dev_session) |
Yes | "All brands" + list of all brands (same UX as production) |
On select:
// src/components/admin/BrandSelector.tsx (client component)
"use client";
async function handleSelect(brandId: string | null) {
await setActiveBrand(brandId); // null = "All brands"
router.refresh();
}
router.refresh() re-runs server components and re-reads the cookie, so all data on the current page reloads in the new brand context. The URL is not changed — the cookie is the source of truth for "what brand am I acting in right now."
URL-level brand params
Keep URL-level brand as-is — URLs are shareable links. The resolution order for the brandId param passed to getActiveBrandId is:
- URL
brandIdparam (if present) active_brand_idcookieadminUser.brand_id(legacy single brand)- First of
adminUser.brand_ids - (platform_admin only)
null→ "all brands"
Migration: supabase/migrations/204_multi_brand_admin.sql
-- 1. Add multi_brand_admin to role CHECK constraint
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
ALTER TABLE admin_users ADD CONSTRAINT admin_users_role_check
CHECK (role IN ('platform_admin', 'brand_admin', 'multi_brand_admin', 'store_employee', 'staff'));
-- 2. Create junction table
CREATE TABLE admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
CREATE INDEX admin_user_brands_brand_id_idx ON admin_user_brands(brand_id);
-- 3. Backfill from existing brand_id (single-brand admins)
INSERT INTO admin_user_brands (admin_user_id, brand_id)
SELECT id, brand_id FROM admin_users
WHERE brand_id IS NOT NULL
ON CONFLICT DO NOTHING;
-- 4. Promote anyone with > 1 brand to multi_brand_admin
UPDATE admin_users
SET role = 'multi_brand_admin'
WHERE role = 'brand_admin'
AND id IN (
SELECT admin_user_id FROM admin_user_brands
GROUP BY admin_user_id HAVING COUNT(*) > 1
);
-- 5. New RPCs for adding/removing brand access
CREATE OR REPLACE FUNCTION add_admin_user_brand(
p_admin_user_id UUID, p_brand_id UUID, p_added_by UUID
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
INSERT INTO admin_user_brands (admin_user_id, brand_id, added_by)
VALUES (p_admin_user_id, p_brand_id, p_added_by)
ON CONFLICT DO NOTHING;
UPDATE admin_users SET role = 'multi_brand_admin'
WHERE id = p_admin_user_id
AND role = 'brand_admin'
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) > 1;
$$;
CREATE OR REPLACE FUNCTION remove_admin_user_brand(
p_admin_user_id UUID, p_brand_id UUID
) RETURNS void LANGUAGE sql SECURITY DEFINER AS $$
DELETE FROM admin_user_brands
WHERE admin_user_id = p_admin_user_id AND brand_id = p_brand_id;
UPDATE admin_users SET role = 'brand_admin'
WHERE id = p_admin_user_id
AND role = 'multi_brand_admin'
AND (SELECT COUNT(*) FROM admin_user_brands WHERE admin_user_id = p_admin_user_id) = 1;
$$;
The admin_users.brand_id column is kept for backwards compat. A follow-up migration drops it after we verify nothing reads it.
Error Handling & Security Boundaries
Three failure modes
-
Requested brand not in
brand_ids(e.g., URL param has a brand the admin doesn't have):getActiveBrandId()returnsnull- Server action returns
{ success: false, error: "Brand access required" } - Page renders
<AdminAccessDenied />with: "You don't have access to that brand. [Switch to a brand you have access to]"
-
Cookie brand no longer in
brand_ids(admin's access was revoked while cookie was still set):getActiveBrandId()falls through toadminUser.brand_id, then first ofbrand_ids- Silent recovery — no error, no UI flash
- The dropped brand just disappears from the dropdown next page load
-
Platform admin acting on a brand they don't own:
- Platform admin:
brand_ids = ["*"](sentinel) — RPCs treat as "all brands" - Server action: never blocks platform admin from any brand
- This is intentional — platform admin = superuser
- Platform admin:
Validation placement (defense in depth)
- Server action:
getActiveBrandId(adminUser, requested)validates. - Server action:
assertBrandAccess(adminUser, brandIdFromUrl)validates (separate, for cases where the brandId comes from URL/form/RPC return rather thangetActiveBrandId). - RPC: still trusts
p_brand_id(SECURITY DEFINER) — application layer is the gate, matching the existing architecture.
Audit logging (additive)
admin_user_brands.added_bycolumn tracks who added an admin to a brand.- Audit log entry on add/remove: out of scope for v1; documented for follow-up.
Testing
Unit tests (vitest — new dev dependency)
src/lib/brand-scope.test.ts:resolveActiveBrandId(platformAdmin, "X")→"X"resolveActiveBrandId(brandAdmin, "X")where X is their brand →"X"resolveActiveBrandId(brandAdmin, "Y")where Y is not their brand →nullgetActiveBrandId(multiBrandAdmin)with cookie set to valid brand → that brandgetActiveBrandId(multiBrandAdmin)with cookie set to revoked brand → first ofbrand_idsassertBrandAccess(...)throws for non-platform-admin with invalid brandsetActiveBrand(null)rejected for non-platform-adminsetActiveBrand("X")rejected for admin without X inbrand_ids
Integration tests (Playwright — already in repo)
tests/admin/multi-brand.spec.ts:- As
multi_brand_admin, dropdown shows 2+ brands - Click brand B → URL stays, cookie updates, page data refreshes to brand B
- Direct-navigate to
/admin/orders?brand=<other-brand>for a brand admin returns access denied - As
platform_admin, "All brands" option is present and works - As
brand_adminwith 1 brand, no dropdown is shown
- As
Migration smoke test (manual, documented in MEMORY.md)
- Before migration: 5 brand_admins exist, each with 1 brand
- After migration: 5 rows in
admin_user_brands, allrole = 'brand_admin' - Create a 6th admin with 2 brands via
add_admin_user_brand→role = 'multi_brand_admin', 2 rows in junction - Remove one of their brands via
remove_admin_user_brand→roledemotes tobrand_admin, 1 row in junction
Out of Scope (v1)
- Per-(admin, brand) permission overrides
- Brand-group / parent-org concept
- "Last accessed brand" auto-redirect
- UI for managing
admin_user_brandsrows (Supabase Studio works for now) - Dropping the legacy
admin_users.brand_idcolumn (follow-up220_*migration) - Audit log entries for add/remove (junction's
added_bycolumn is the seed)
Implementation Order
- Migration
204_multi_brand_admin.sqlapplied src/lib/brand-scope.ts+ unit tests (TDD)AdminUsertype updated;getAdminUser()returnsbrand_idssetActiveBrandserver action + testslistBrandsForAdminserver function + tests- BrandSelector UI component
- Wire BrandSelector into AdminHeader
- Server action migration (~30 actions) — mechanical one-line swap each
- Page server component migration (~10+ pages) — same pattern
- Playwright integration tests
- Manual smoke test of the migration on dev DB