This commit is contained in:
Dhananjaya99
2026-07-18 23:42:58 +05:30
parent 80b130dffb
commit 92c4b14a6c
55 changed files with 8815 additions and 43 deletions
+17 -14
View File
@@ -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>
)
}
+9 -1
View File
@@ -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)