add rbac
This commit is contained in:
@@ -16,57 +16,77 @@ import {
|
||||
PackageCheck,
|
||||
Ruler,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
SlidersHorizontal,
|
||||
SwatchBook,
|
||||
Tag,
|
||||
Truck,
|
||||
Users,
|
||||
Warehouse,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
||||
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||
// so role-based filtering (via GET /auth/me's navCodes) can match entries here.
|
||||
const navItems: {
|
||||
title: string
|
||||
code: string
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
chevron?: boolean
|
||||
children?: { title: string; href: string; icon: LucideIcon }[]
|
||||
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
|
||||
}[] = [
|
||||
{ title: "Dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||
{ title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid },
|
||||
{
|
||||
title: "Products",
|
||||
code: "products",
|
||||
href: "/dashboard/products",
|
||||
icon: Package,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "Item Type", href: "/dashboard/products/item-types", icon: SwatchBook },
|
||||
{ title: "UOM", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
{ title: "Configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "Item Type", code: "products.item-type", href: "/dashboard/products/item-types", icon: SwatchBook },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
{ title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
{ title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
|
||||
{ title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{ title: "Settings", href: "/dashboard/settings", icon: Settings, chevron: true },
|
||||
{ title: "Help", href: "/dashboard/help", icon: HelpCircle },
|
||||
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
|
||||
{ title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true },
|
||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||
{
|
||||
title: "Settings",
|
||||
code: "settings",
|
||||
href: "/dashboard/settings",
|
||||
icon: Settings,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||
],
|
||||
},
|
||||
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
|
||||
]
|
||||
|
||||
function SidebarContent({
|
||||
items,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onClose,
|
||||
pathname,
|
||||
isMobile,
|
||||
}: {
|
||||
items: typeof navItems
|
||||
collapsed: boolean
|
||||
onCollapse: () => void
|
||||
onClose?: () => void
|
||||
@@ -110,7 +130,7 @@ function SidebarContent({
|
||||
|
||||
{/* Nav items */}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href)
|
||||
|
||||
@@ -195,6 +215,19 @@ export function AppSidebar() {
|
||||
const pathname = usePathname() || "/"
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { navCodes, loading } = useAuth()
|
||||
|
||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
.filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children?.filter((c) => navCodes.includes(c.code)),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
@@ -213,6 +246,7 @@ export function AppSidebar() {
|
||||
{/* ── Desktop sidebar ─────────────────────────────── */}
|
||||
<div className="my-3 hidden lg:my-4 lg:flex">
|
||||
<SidebarContent
|
||||
items={visibleItems}
|
||||
collapsed={collapsed}
|
||||
onCollapse={() => setCollapsed((v) => !v)}
|
||||
pathname={pathname}
|
||||
@@ -247,6 +281,7 @@ export function AppSidebar() {
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
items={visibleItems}
|
||||
collapsed={false}
|
||||
onCollapse={() => {}}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
// Authoritative session context: fetches GET /auth/me once per mount and exposes
|
||||
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
|
||||
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||
// (that cache remains display-only, e.g. for the header's user name).
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
|
||||
interface AuthContextValue {
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
/** True until the first `/auth/me` response lands. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
roleCode: null,
|
||||
roleName: null,
|
||||
navCodes: [],
|
||||
loading: true,
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => {
|
||||
if (!cancelled) setMe(res)
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [] })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
roleCode: me?.roleCode ?? null,
|
||||
roleName: me?.roleName ?? null,
|
||||
navCodes: me?.navCodes ?? [],
|
||||
loading: me === null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
// Checkbox tree for assigning a role's visible sidebar sections. Leaf nav items
|
||||
// (no children) are toggled directly; parent nav items with children are a
|
||||
// "select all children" convenience toggle — the parent's own visibility is
|
||||
// derived from its children on the frontend (see AppSidebar.tsx's filter), so
|
||||
// only the children need to carry the actual grant for those groups.
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { NavItem } from "@/types/rbac"
|
||||
|
||||
interface Props {
|
||||
tree: NavItem[]
|
||||
selectedNavItemIds: Set<number>
|
||||
selectedSubNavItemIds: Set<number>
|
||||
onChange: (navItemIds: Set<number>, subNavItemIds: Set<number>) => void
|
||||
}
|
||||
|
||||
export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavItemIds, onChange }: Props) {
|
||||
function toggleLeaf(navItemId: number) {
|
||||
const next = new Set(selectedNavItemIds)
|
||||
if (next.has(navItemId)) next.delete(navItemId)
|
||||
else next.add(navItemId)
|
||||
onChange(next, selectedSubNavItemIds)
|
||||
}
|
||||
|
||||
function toggleChild(subNavItemId: number) {
|
||||
const next = new Set(selectedSubNavItemIds)
|
||||
if (next.has(subNavItemId)) next.delete(subNavItemId)
|
||||
else next.add(subNavItemId)
|
||||
onChange(selectedNavItemIds, next)
|
||||
}
|
||||
|
||||
function toggleAllChildren(item: NavItem, checked: boolean) {
|
||||
const next = new Set(selectedSubNavItemIds)
|
||||
for (const child of item.children) {
|
||||
if (checked) next.add(child.subNavItemId)
|
||||
else next.delete(child.subNavItemId)
|
||||
}
|
||||
onChange(selectedNavItemIds, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border p-4">
|
||||
{tree.map((item) => {
|
||||
if (item.children.length === 0) {
|
||||
return (
|
||||
<label key={item.navItemId} className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={selectedNavItemIds.has(item.navItemId)}
|
||||
onCheckedChange={() => toggleLeaf(item.navItemId)}
|
||||
/>
|
||||
<Label className="cursor-pointer text-base font-medium">{item.label}</Label>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length
|
||||
const allChecked = checkedCount === item.children.length
|
||||
|
||||
return (
|
||||
<div key={item.navItemId} className="flex flex-col gap-1 border-t pt-2 first:border-t-0 first:pt-0">
|
||||
<label className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted/50">
|
||||
<Checkbox checked={allChecked} onCheckedChange={(checked) => toggleAllChildren(item, !!checked)} />
|
||||
<Label className="cursor-pointer text-base font-semibold">
|
||||
{item.label} {checkedCount > 0 && !allChecked && <span className="text-sm font-normal text-muted-foreground">({checkedCount} of {item.children.length})</span>}
|
||||
</Label>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 pl-9">
|
||||
{item.children.map((child) => (
|
||||
<label key={child.subNavItemId} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={selectedSubNavItemIds.has(child.subNavItemId)}
|
||||
onCheckedChange={() => toggleChild(child.subNavItemId)}
|
||||
/>
|
||||
<Label className="cursor-pointer text-sm">{child.label}</Label>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user