diff --git a/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md b/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md new file mode 100644 index 0000000..10391a1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md @@ -0,0 +1,470 @@ +# 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` + +```sql +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 + +```ts +// 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: , brand_ids: []` — fetched from the `brands` table so dev store_employee can browse a real brand's data. (If no brands exist, dev store_employee sees `` — 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` + +```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 { + // 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 { + 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 { + 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 + +```ts +// 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 + +```ts +// 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 + +```ts +// 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 + +```ts +// 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 ; + + const activeBrandId = await getActiveBrandId(adminUser, brandIdParam); + + if (!activeBrandId && adminUser.role !== "platform_admin") { + return ; + } + + // ... 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:** + +```ts +// 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` + +```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 `` 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=` 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_brand` → `role = 'multi_brand_admin'`, 2 rows in junction +- Remove one of their brands via `remove_admin_user_brand` → `role` 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