feat(admin): multi-brand admin support
Implements the design at
docs/superpowers/specs/2026-06-04-multi-brand-admin-design.md.
Adds:
- admin_user_brands junction table (m:n admin<->brand) via migration 207
- New role 'multi_brand_admin' (auto-set when an admin has 2+ brands)
- 'active_brand_id' cookie that persists the admin's currently-selected
brand across navigations; switchable via the new BrandSelector dropdown
in the sidebar
- Centralised brand resolution in src/lib/brand-scope.ts:
- getActiveBrandId (URL > cookie > legacy brand_id > first of brand_ids)
- assertBrandAccess (defence-in-depth for cases where the brandId
comes from a URL form or RPC return)
- ~30 server actions and ~10 page server components migrated to use
getActiveBrandId instead of the silent brandId ?? adminUser.brand_id
pattern that allowed cross-brand access bugs
- BrandSelector client component with proper a11y (aria-haspopup,
aria-expanded, role=listbox, outside-click and escape-to-close)
Migration 207 also adds RLS so admins can read their own junction rows
(needed for the dropdown to populate) and SECURITY DEFINER RPCs
add/remove_admin_user_brand that auto-promote/demote between
brand_admin and multi_brand_admin.
Notes:
- Migration number is 207 not 204 — 204-206 were taken in this worktree
by the concurrent locations work.
- The legacy admin_users.brand_id column is preserved for backwards
compat; a follow-up migration 220_* will drop it.
- Dev sessions (dev_session cookie, NEXT_PUBLIC_USE_MOCK_DATA) get
brand_ids: []; the documented limitation is that dev store_employee
will see <AdminAccessDenied /> if no real brands exist.
- Pages that hardcode a Tuxedo brand UUID as a fallback
(adminUser.brand_id ?? '64294306-...') are NOT migrated in this PR —
they still work for single-brand admins and are out of scope.
Co-authored-by: implementer subagent (cancelled mid-run), Grok orchestrator
This commit is contained in:
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import BrandSelector from "./BrandSelector";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -217,9 +218,12 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
|
||||
type SidebarProps = {
|
||||
userRole?: string | null;
|
||||
brandIds?: string[];
|
||||
activeBrandId?: string | null;
|
||||
brands?: Array<{ id: string; name: string; slug: string; logo_url: string | null }>;
|
||||
};
|
||||
|
||||
export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
export default function AdminSidebar({ userRole, brandIds, activeBrandId, brands }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -230,9 +234,15 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
|
||||
const roleLabel = userRole === "platform_admin" ? "Platform Admin"
|
||||
: userRole === "brand_admin" ? "Brand Admin"
|
||||
: userRole === "multi_brand_admin" ? "Multi-Brand Manager"
|
||||
: userRole === "store_employee" ? "Store Employee"
|
||||
: null;
|
||||
|
||||
const isMultiBrandAdmin = userRole === "multi_brand_admin";
|
||||
const showAllBrandsOption = userRole === "platform_admin";
|
||||
const showBrandSelector =
|
||||
brands && (showAllBrandsOption || (brandIds && brandIds.length >= 2));
|
||||
|
||||
const isActive = useCallback((href: string) => {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname.startsWith(href);
|
||||
@@ -394,6 +404,24 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Brand selector (multi-brand admins + platform_admin) */}
|
||||
{showBrandSelector && (
|
||||
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(208, 203, 180, 0.2)" }}>
|
||||
<p
|
||||
className="text-[10px] font-semibold uppercase tracking-widest mb-1.5 px-1"
|
||||
style={{ color: "rgba(195, 195, 193, 0.6)" }}
|
||||
>
|
||||
Active Brand
|
||||
</p>
|
||||
<BrandSelector
|
||||
brands={brands!}
|
||||
activeBrandId={activeBrandId ?? null}
|
||||
showAllBrandsOption={showAllBrandsOption}
|
||||
isMultiBrandAdmin={isMultiBrandAdmin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav links with keyboard navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 scrollbar-thin">
|
||||
<ul className="space-y-1" role="list">
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setActiveBrand } from "@/actions/set-active-brand";
|
||||
|
||||
export type BrandSelectorItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
logo_url: string | null;
|
||||
};
|
||||
|
||||
type BrandSelectorProps = {
|
||||
brands: BrandSelectorItem[];
|
||||
/** Currently-active brand id. `null` means "All brands" (platform_admin only). */
|
||||
activeBrandId: string | null;
|
||||
/** When true, shows the "All brands" pseudo-option at the top. */
|
||||
showAllBrandsOption: boolean;
|
||||
/** Whether the user has multi-brand access. Controls the "Multi-brand manager" badge. */
|
||||
isMultiBrandAdmin: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Brand selector dropdown.
|
||||
*
|
||||
* Renders a compact pill showing the active brand (or "All brands" for
|
||||
* platform_admin) and opens a list of accessible brands on click.
|
||||
*
|
||||
* On select: calls `setActiveBrand` server action then `router.refresh()`
|
||||
* so all server components re-read the new cookie and reload their data.
|
||||
* The URL is NOT changed — the cookie is the source of truth.
|
||||
*/
|
||||
export default function BrandSelector({
|
||||
brands,
|
||||
activeBrandId,
|
||||
showAllBrandsOption,
|
||||
isMultiBrandAdmin,
|
||||
}: BrandSelectorProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [open]);
|
||||
|
||||
// No brands + no "All brands" option = don't render
|
||||
if (!showAllBrandsOption && brands.length === 0) return null;
|
||||
if (!showAllBrandsOption && brands.length < 2) return null;
|
||||
|
||||
const activeBrand = brands.find((b) => b.id === activeBrandId);
|
||||
const label =
|
||||
showAllBrandsOption && !activeBrand
|
||||
? "All brands"
|
||||
: activeBrand?.name ?? "Select brand";
|
||||
|
||||
async function selectBrand(brandId: string | null) {
|
||||
setOpen(false);
|
||||
if (brandId === activeBrandId) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await setActiveBrand(brandId);
|
||||
if (res.success) {
|
||||
router.refresh();
|
||||
} else {
|
||||
// Keep UI usable if the server rejected; log for debugging
|
||||
console.error("setActiveBrand failed:", res.error);
|
||||
}
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
disabled={pending}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg border transition-colors text-left disabled:opacity-60"
|
||||
style={{
|
||||
backgroundColor: "rgba(208, 203, 180, 0.1)",
|
||||
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||
color: "var(--admin-sidebar-text)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{showAllBrandsOption && !activeBrand ? "*" : label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium truncate">
|
||||
{label}
|
||||
</span>
|
||||
{isMultiBrandAdmin && (
|
||||
<span
|
||||
className="text-[9px] font-medium px-1.5 py-0.5 rounded-md flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: "rgba(202, 117, 67, 0.18)",
|
||||
color: "var(--admin-accent)",
|
||||
}}
|
||||
title="Manages multiple brands"
|
||||
>
|
||||
multi
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-3 h-3 flex-shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 mt-1.5 rounded-xl border shadow-2xl z-50 overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-sidebar-bg)",
|
||||
borderColor: "rgba(208, 203, 180, 0.25)",
|
||||
}}
|
||||
>
|
||||
{showAllBrandsOption && (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!activeBrand}
|
||||
onClick={() => selectBrand(null)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
color: !activeBrand ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||
backgroundColor: !activeBrand ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
<span className="flex-1">All brands</span>
|
||||
{!activeBrand && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{brands.map((b) => {
|
||||
const isActive = b.id === activeBrandId;
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => selectBrand(b.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-left transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
color: isActive ? "var(--admin-accent)" : "var(--admin-sidebar-text)",
|
||||
backgroundColor: isActive ? "rgba(202, 117, 67, 0.10)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-semibold flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)", color: "white" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{b.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
{isActive && (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: "var(--admin-accent)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user