Files
route-commerce/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md
T
tyler 9b51f5ae29 docs: multi-brand admin support design spec
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
2026-06-04 16:29:40 +00:00

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_users rows (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_admin role makes the relationship explicit in the data and the UI.
  • platform_admin continues to work unchanged (gets all brands implicitly).
  • Existing single-brand brand_admin, store_employee, and staff users 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_brands rows. Use Supabase Studio or a follow-up admin UI PR.
  • Dropping the legacy admin_users.brand_id column. A follow-up migration 220_drop_legacy_brand_id.sql will 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_id makes "which admins are in Brand X?" queries fast.
  • added_at / added_by for audit trail.
  • ON DELETE CASCADE for 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

  1. If dev_session cookie set → return dev admin. For platform_admin dev: brand_id: null, brand_ids: [] (resolved against brands table by listBrandsForAdmin). For store_employee dev: brand_id: <first-real-brand-id>, brand_ids: [<that-id>] — fetched from the brands table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees <AdminAccessDenied /> — known limitation.)
  2. If NEXT_PUBLIC_USE_MOCK_DATA=true → same as platform_admin dev.
  3. Real auth → load admin_users row, then JOIN admin_user_brands to populate brand_ids.
  4. Set brand_id from (in order): active_brand_id cookie (if in brand_ids for non-platform-admin, or always for platform-admin) → admin_users.brand_id (if in brand_ids) → first of brand_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.ts
  • src/actions/communications/templates.ts
  • src/actions/communications/campaigns.ts
  • src/actions/orders/create-admin-order.ts
  • src/actions/stops.ts
  • src/actions/analytics.ts
  • src/actions/square-sync-ui.ts
  • src/actions/shipping.ts
  • src/actions/payments.ts
  • src/actions/ai-import.ts
  • src/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:

  1. URL brandId param (if present)
  2. active_brand_id cookie
  3. adminUser.brand_id (legacy single brand)
  4. First of adminUser.brand_ids
  5. (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

  1. Requested brand not in brand_ids (e.g., URL param has a brand the admin doesn't have):

    • getActiveBrandId() returns null
    • 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]"
  2. Cookie brand no longer in brand_ids (admin's access was revoked while cookie was still set):

    • getActiveBrandId() falls through to adminUser.brand_id, then first of brand_ids
    • Silent recovery — no error, no UI flash
    • The dropped brand just disappears from the dropdown next page load
  3. 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

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 than getActiveBrandId).
  • RPC: still trusts p_brand_id (SECURITY DEFINER) — application layer is the gate, matching the existing architecture.

Audit logging (additive)

  • admin_user_brands.added_by column 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 → null
    • getActiveBrandId(multiBrandAdmin) with cookie set to valid brand → that brand
    • getActiveBrandId(multiBrandAdmin) with cookie set to revoked brand → first of brand_ids
    • assertBrandAccess(...) throws for non-platform-admin with invalid brand
    • setActiveBrand(null) rejected for non-platform-admin
    • setActiveBrand("X") rejected for admin without X in brand_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_admin with 1 brand, no dropdown is shown

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, all role = 'brand_admin'
  • Create a 6th admin with 2 brands via add_admin_user_brandrole = 'multi_brand_admin', 2 rows in junction
  • Remove one of their brands via remove_admin_user_brandrole demotes to brand_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_brands rows (Supabase Studio works for now)
  • Dropping the legacy admin_users.brand_id column (follow-up 220_* migration)
  • Audit log entries for add/remove (junction's added_by column is the seed)

Implementation Order

  1. Migration 204_multi_brand_admin.sql applied
  2. src/lib/brand-scope.ts + unit tests (TDD)
  3. AdminUser type updated; getAdminUser() returns brand_ids
  4. setActiveBrand server action + tests
  5. listBrandsForAdmin server function + tests
  6. BrandSelector UI component
  7. Wire BrandSelector into AdminHeader
  8. Server action migration (~30 actions) — mechanical one-line swap each
  9. Page server component migration (~10+ pages) — same pattern
  10. Playwright integration tests
  11. Manual smoke test of the migration on dev DB