add rbac
This commit is contained in:
@@ -2,6 +2,7 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar"
|
||||
import { Header } from "@/components/Layouts/Header"
|
||||
import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs"
|
||||
import { Toaster } from "@/components/ui/toast"
|
||||
import { AuthProvider } from "@/components/auth/AuthProvider"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
@@ -9,22 +10,24 @@ export default function DashboardLayout({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
<AuthProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
<div className="rounded-xl bg-card border border-gray-200 shadow-sm">
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NavItem, Role } from "@/types/rbac"
|
||||
|
||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function RoleDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const roleId = Number(params.id)
|
||||
|
||||
const [role, setRole] = useState<Role | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
const [code, setCode] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingPermissions, setSavingPermissions] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
Promise.all([rolesApi.get(roleId), navApi.tree(), rolesApi.getPermissions(roleId)])
|
||||
.then(([roleResult, navTree, permissions]) => {
|
||||
setRole(roleResult.data)
|
||||
setEtag(roleResult.etag)
|
||||
setCode(roleResult.data.code)
|
||||
setName(roleResult.data.name)
|
||||
setTree(navTree)
|
||||
setNavItemIds(new Set(permissions.navItemIds))
|
||||
setSubNavItemIds(new Set(permissions.subNavItemIds))
|
||||
setConflict(false)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(roleId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roleId])
|
||||
|
||||
async function handleSave() {
|
||||
setSaveError(null)
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!code.trim()) nextErrors.code = "Role code is required"
|
||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0 || !etag) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await rolesApi.update(roleId, { code: code.trim().toUpperCase(), name: name.trim() }, etag)
|
||||
setRole(result.data)
|
||||
setEtag(result.etag)
|
||||
toast.success("Role saved", `${result.data.code} — ${result.data.name}`)
|
||||
} catch (err) {
|
||||
const errCode = (err as { code?: string })?.code
|
||||
if (errCode === "CONCURRENCY_CONFLICT") {
|
||||
setConflict(true)
|
||||
setSaveError(errorMessage(err))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
const fe = fieldErrors(err)
|
||||
if (fe?.code) setErrors({ code: fe.code })
|
||||
setSaveError(errorMessage(err))
|
||||
toast.error("Could not save role", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
setSavingPermissions(true)
|
||||
try {
|
||||
await rolesApi.assignPermissions(roleId, {
|
||||
navItemIds: Array.from(navItemIds),
|
||||
subNavItemIds: Array.from(subNavItemIds),
|
||||
})
|
||||
toast.success("Permissions saved", "This role's visible sidebar sections have been updated.")
|
||||
} catch (err) {
|
||||
toast.error("Could not save permissions", errorMessage(err))
|
||||
} finally {
|
||||
setSavingPermissions(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !role) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to roles
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!role || !tree) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
role.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{role.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{role.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
|
||||
<AlertTriangle className="size-5 shrink-0" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>{saveError ?? "This role was changed by someone else."} Reload before retrying.</p>
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && !conflict && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{saveError}</div>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Details</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Code</Label>
|
||||
<Input value={code} readOnly disabled className="h-12 text-base text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={saving || conflict}>
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving…" : "Save details"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
|
||||
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
|
||||
</div>
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selectedNavItemIds={navItemIds}
|
||||
selectedSubNavItemIds={subNavItemIds}
|
||||
onChange={(nav, sub) => {
|
||||
setNavItemIds(nav)
|
||||
setSubNavItemIds(sub)
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSavePermissions} disabled={savingPermissions}>
|
||||
<Save className="size-4" />
|
||||
{savingPermissions ? "Saving…" : "Save permissions"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={() => router.push("/dashboard/settings/roles")}>
|
||||
Back to roles
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react"
|
||||
|
||||
import { navApi } from "@/lib/api/nav"
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { NavItem, Role } from "@/types/rbac"
|
||||
|
||||
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */
|
||||
function deriveCode(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
}
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<Role[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [tree, setTree] = useState<NavItem[] | null>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState("")
|
||||
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
|
||||
|
||||
function load() {
|
||||
rolesApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setRoles(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(() => {
|
||||
navApi.tree().then(setTree).catch(() => setTree([]))
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setName("")
|
||||
setNavItemIds(new Set())
|
||||
setSubNavItemIds(new Set())
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const code = deriveCode(name)
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!name.trim()) nextErrors.name = "Role name is required"
|
||||
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await rolesApi.create({ code, name: name.trim() })
|
||||
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
|
||||
await rolesApi.assignPermissions(result.data.roleId, {
|
||||
navItemIds: Array.from(navItemIds),
|
||||
subNavItemIds: Array.from(subNavItemIds),
|
||||
})
|
||||
}
|
||||
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not create role", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(role: Role) {
|
||||
setActionPendingId(role.roleId)
|
||||
try {
|
||||
await rolesApi.remove(role.roleId)
|
||||
toast.success("Role deleted", `${role.code} has been removed.`)
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error("Could not delete role", errorMessage(err))
|
||||
} finally {
|
||||
setActionPendingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Roles</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Manage roles and which sidebar sections each one can see.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v)
|
||||
if (!v) resetForm()
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New Role
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New role</DialogTitle>
|
||||
<DialogDescription>Created in both the auth service and here.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.name}>
|
||||
<FieldLabel htmlFor="r-name">Name</FieldLabel>
|
||||
<Input id="r-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Manager" aria-invalid={!!errors.name} />
|
||||
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="r-code">Code (auto-generated)</FieldLabel>
|
||||
<Input id="r-code" value={code} disabled readOnly placeholder="—" className="text-muted-foreground" />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FieldLabel>Sidebar permissions</FieldLabel>
|
||||
{tree === null ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : (
|
||||
<div className="max-h-80 overflow-auto">
|
||||
<RolePermissionTree
|
||||
tree={tree}
|
||||
selectedNavItemIds={navItemIds}
|
||||
selectedSubNavItemIds={subNavItemIds}
|
||||
onChange={(nav, sub) => {
|
||||
setNavItemIds(nav)
|
||||
setSubNavItemIds(sub)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && roles === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShieldCheck className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No roles yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && roles !== null && roles.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((r) => (
|
||||
<TableRow key={r.roleId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{r.code}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{r.name}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
r.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/dashboard/settings/roles/${r.roleId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${r.code}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
aria-label={`Delete ${r.code}`}
|
||||
disabled={r.isSystemRole || actionPendingId === r.roleId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="destructive"
|
||||
title={`Delete ${r.code}?`}
|
||||
description={`This removes the role from both the auth service and here. Blocked if any user still holds it.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDelete(r)}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const userId = Number(params.id)
|
||||
|
||||
const [user, setUser] = useState<ManagedUser | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function load() {
|
||||
setLoadError(null)
|
||||
Promise.all([usersApi.get(userId), rolesApi.list({ pageSize: 200, status: "Active" })])
|
||||
.then(([u, roleList]) => {
|
||||
setUser(u)
|
||||
setRoles(roleList.items)
|
||||
setRoleId(u.roleId ? String(u.roleId) : "")
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Number.isFinite(userId)) load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId])
|
||||
|
||||
async function handleSave() {
|
||||
if (!roleId) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await usersApi.updateRole(userId, { roleId: Number(roleId) })
|
||||
setUser(result)
|
||||
toast.success("Role updated", `${result.username} is now assigned to ${result.roleName}.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not update role", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError && !user) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
||||
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
Back to users
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
user.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{user.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{user.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:max-w-sm">
|
||||
<Label className="text-base">Role</Label>
|
||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" size="lg" onClick={() => router.push("/dashboard/settings/users")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="lg" onClick={handleSave} disabled={saving || !roleId}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save role"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Pencil, Plus, Users as UsersIcon } from "lucide-react"
|
||||
|
||||
import { rolesApi } from "@/lib/api/roles"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Role } from "@/types/rbac"
|
||||
import { ManagedUser, UserTypeOption } from "@/types/users"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<ManagedUser[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [userTypes, setUserTypes] = useState<UserTypeOption[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [username, setUsername] = useState("")
|
||||
const [fullName, setFullName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [mobileNumber, setMobileNumber] = useState("")
|
||||
const [nic, setNic] = useState("")
|
||||
const [roleId, setRoleId] = useState<string>("")
|
||||
const [userTypeId, setUserTypeId] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function load() {
|
||||
usersApi
|
||||
.list({ page, pageSize: PAGE_SIZE })
|
||||
.then((res) => {
|
||||
setUsers(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [page])
|
||||
|
||||
useEffect(() => {
|
||||
rolesApi.list({ pageSize: 200, status: "Active" }).then((res) => setRoles(res.items)).catch(() => setRoles([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.userTypes()
|
||||
.then((types) => {
|
||||
setUserTypes(types)
|
||||
// Only one user type exists today (AuthHex's "Admin"/"dev" seed) — default to it
|
||||
// so the admin never has to pick a raw GUID; the select still lets them switch
|
||||
// if more types are added later.
|
||||
if (types.length > 0) setUserTypeId((current) => current || types[0].userTypeId)
|
||||
})
|
||||
.catch(() => setUserTypes([]))
|
||||
}, [])
|
||||
|
||||
function resetForm() {
|
||||
setUsername("")
|
||||
setFullName("")
|
||||
setEmail("")
|
||||
setMobileNumber("")
|
||||
setNic("")
|
||||
setRoleId("")
|
||||
setUserTypeId("")
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!username.trim()) nextErrors.username = "Username is required"
|
||||
if (!fullName.trim()) nextErrors.fullName = "Full name is required"
|
||||
if (!email.trim()) nextErrors.email = "Email is required"
|
||||
if (!roleId) nextErrors.roleId = "Role is required"
|
||||
if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await usersApi.create({
|
||||
username: username.trim(),
|
||||
fullName: fullName.trim(),
|
||||
email: email.trim(),
|
||||
mobileNumber: mobileNumber || null,
|
||||
nic: nic || null,
|
||||
roleId: Number(roleId),
|
||||
userTypeId: userTypeId.trim(),
|
||||
})
|
||||
toast.success("User created", `Credentials have been emailed to ${result.username}.`)
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
load()
|
||||
} catch (err) {
|
||||
const fe = fieldErrors(err)
|
||||
if (fe) setErrors(fe)
|
||||
toast.error("Could not create user", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Users</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Create accounts and assign roles. New users receive their credentials by email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button size="lg">
|
||||
<Plus className="size-5" />
|
||||
New User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="items-center text-center">
|
||||
<DialogTitle>New user</DialogTitle>
|
||||
<DialogDescription>Created in both the auth service and here; password is emailed.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.username}>
|
||||
<FieldLabel htmlFor="u-username">Username</FieldLabel>
|
||||
<Input id="u-username" value={username} onChange={(e) => setUsername(e.target.value)} aria-invalid={!!errors.username} />
|
||||
<FieldError errors={[errors.username ? { message: errors.username } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.fullName}>
|
||||
<FieldLabel htmlFor="u-fullname">Full name</FieldLabel>
|
||||
<Input id="u-fullname" value={fullName} onChange={(e) => setFullName(e.target.value)} aria-invalid={!!errors.fullName} />
|
||||
<FieldError errors={[errors.fullName ? { message: errors.fullName } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.email}>
|
||||
<FieldLabel htmlFor="u-email">Email</FieldLabel>
|
||||
<Input id="u-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} />
|
||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="u-mobile">Mobile number (optional)</FieldLabel>
|
||||
<Input id="u-mobile" value={mobileNumber} onChange={(e) => setMobileNumber(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="u-nic">NIC (optional)</FieldLabel>
|
||||
<Input id="u-nic" value={nic} onChange={(e) => setNic(e.target.value)} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.roleId}>
|
||||
<FieldLabel htmlFor="u-role">Role</FieldLabel>
|
||||
<Select value={roleId || undefined} onValueChange={(v) => setRoleId(v ?? "")}>
|
||||
<SelectTrigger id="u-role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((r) => (
|
||||
<SelectItem key={r.roleId} value={String(r.roleId)}>
|
||||
{r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.roleId ? { message: errors.roleId } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.userTypeId}>
|
||||
<FieldLabel htmlFor="u-usertype">User type</FieldLabel>
|
||||
<Select value={userTypeId || undefined} onValueChange={(v) => setUserTypeId(v ?? "")}>
|
||||
<SelectTrigger id="u-usertype">
|
||||
<SelectValue placeholder="Select a user type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userTypes.map((t) => (
|
||||
<SelectItem key={t.userTypeId} value={t.userTypeId}>
|
||||
{t.code ?? t.userTypeId}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.userTypeId ? { message: errors.userTypeId } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Button variant="outline" className="min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-w-36" onClick={handleCreate} disabled={submitting}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && users === null && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<UsersIcon className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No users yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && users !== null && users.length > 0 && (
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Username</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Display name</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Role</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.userId}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{u.username}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{u.displayName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{u.roleName ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||
u.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{u.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Link
|
||||
href={`/dashboard/settings/users/${u.userId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Edit ${u.username}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
|
||||
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Suspense, useState } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
@@ -41,6 +41,14 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
// delivers the session as httpOnly cookies — there is no token for JS to hold or attach.
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
|
||||
export const authApi = {
|
||||
/** Authoritative role + permitted sidebar nav codes for the current session. */
|
||||
me(): Promise<MeResponse> {
|
||||
return apiRequest<MeResponse>("/auth/me")
|
||||
},
|
||||
|
||||
/** Sets erp_at / erp_rt / XSRF-TOKEN cookies on success. Body carries no tokens. */
|
||||
login(request: LoginRequest): Promise<AuthSession> {
|
||||
return apiRequest<AuthSession>("/auth/login", { method: "POST", body: request })
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Read-only sidebar nav tree (ERPCore Controllers/NavController.cs).
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import { NavItem } from "@/types/rbac"
|
||||
|
||||
export const navApi = {
|
||||
tree(): Promise<NavItem[]> {
|
||||
return apiRequest<NavItem[]>("/nav")
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Role CRUD + permission assignment (ERPCore Controllers/RolesController.cs).
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
AssignRolePermissionsRequest,
|
||||
CreateRoleRequest,
|
||||
Role,
|
||||
RolePermissions,
|
||||
UpdateRoleRequest,
|
||||
} from "@/types/rbac"
|
||||
|
||||
export interface ListRolesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
}
|
||||
|
||||
export const rolesApi = {
|
||||
list(params: ListRolesParams = {}): Promise<PagedResponse<Role>> {
|
||||
return apiRequest<PagedResponse<Role>>(`/roles${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(roleId: number): Promise<ApiResult<Role>> {
|
||||
return apiRequestWithETag<Role>(`/roles/${roleId}`)
|
||||
},
|
||||
|
||||
create(request: CreateRoleRequest): Promise<ApiResult<Role>> {
|
||||
return apiRequestWithETag<Role>("/roles", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
update(roleId: number, request: UpdateRoleRequest, ifMatch: string): Promise<ApiResult<Role>> {
|
||||
return apiRequestWithETag<Role>(`/roles/${roleId}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
|
||||
updateStatus(roleId: number, status: EntityStatus): Promise<void> {
|
||||
return apiRequest<void>(`/roles/${roleId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
|
||||
remove(roleId: number): Promise<void> {
|
||||
return apiRequest<void>(`/roles/${roleId}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
getPermissions(roleId: number): Promise<RolePermissions> {
|
||||
return apiRequest<RolePermissions>(`/roles/${roleId}/permissions`)
|
||||
},
|
||||
|
||||
assignPermissions(roleId: number, request: AssignRolePermissionsRequest): Promise<RolePermissions> {
|
||||
return apiRequest<RolePermissions>(`/roles/${roleId}/permissions`, { method: "PUT", body: request })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// User management (ERPCore Controllers/UsersController.cs). Create orchestrates
|
||||
// account creation in both AuthHex and ERPCore's local shadow table server-side.
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UserTypeOption } from "@/types/users"
|
||||
|
||||
export interface ListUsersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
list(params: ListUsersParams = {}): Promise<PagedResponse<ManagedUser>> {
|
||||
return apiRequest<PagedResponse<ManagedUser>>(`/users${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(userId: number): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>(`/users/${userId}`)
|
||||
},
|
||||
|
||||
create(request: CreateUserRequest): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>("/users", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
updateRole(userId: number, request: UpdateUserRoleRequest): Promise<ManagedUser> {
|
||||
return apiRequest<ManagedUser>(`/users/${userId}/role`, { method: "PUT", body: request })
|
||||
},
|
||||
|
||||
userTypes(): Promise<UserTypeOption[]> {
|
||||
return apiRequest<UserTypeOption[]>("/users/user-types")
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Role / Nav / Permission DTOs (mirrors ERPCore's Dtos/Rbac/*.cs exactly).
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export interface Role {
|
||||
roleId: number
|
||||
code: string
|
||||
name: string
|
||||
isSystemRole: boolean
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SubNavItem {
|
||||
subNavItemId: number
|
||||
code: string
|
||||
label: string
|
||||
icon: string | null
|
||||
href: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
navItemId: number
|
||||
code: string
|
||||
label: string
|
||||
icon: string | null
|
||||
href: string | null
|
||||
sortOrder: number
|
||||
children: SubNavItem[]
|
||||
}
|
||||
|
||||
export interface RolePermissions {
|
||||
roleId: number
|
||||
navItemIds: number[]
|
||||
subNavItemIds: number[]
|
||||
}
|
||||
|
||||
export interface AssignRolePermissionsRequest {
|
||||
navItemIds: number[]
|
||||
subNavItemIds: number[]
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Managed-user DTOs (mirrors ERPCore's Dtos/Users/UserDtos.cs).
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export interface ManagedUser {
|
||||
userId: number
|
||||
username: string
|
||||
displayName: string
|
||||
status: EntityStatus
|
||||
roleId: number | null
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string
|
||||
fullName: string
|
||||
roleId: number
|
||||
userTypeId: string
|
||||
email: string
|
||||
nic?: string | null
|
||||
mobileNumber?: string | null
|
||||
/** Left empty to auto-generate — AuthHex emails it to `email`. */
|
||||
password?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateUserRoleRequest {
|
||||
roleId: number
|
||||
}
|
||||
|
||||
/** AuthHex UserType lookup, for the create-user form's select (no local shadow — read-only). */
|
||||
export interface UserTypeOption {
|
||||
userTypeId: string
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user