add rbac
This commit is contained in:
@@ -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