diff --git a/src/__tests__/rbac/group-permissions.test.ts b/src/__tests__/rbac/group-permissions.test.ts new file mode 100644 index 0000000..9aeeaa7 --- /dev/null +++ b/src/__tests__/rbac/group-permissions.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest" + +import { groupPermissionsByMenu, GLOBAL_GROUP_TITLE } from "@/lib/rbac/group-permissions" +import type { PermissionDetail } from "@/types/iam/role" + +function perm(overrides: Partial): PermissionDetail { + return { + permissionId: overrides.permissionId ?? "id", + permissionCode: overrides.permissionCode ?? "svc.mod.ent.view", + permissionName: overrides.permissionName ?? "Name", + description: overrides.description ?? "desc", + serviceName: overrides.serviceName ?? "svc", + moduleName: overrides.moduleName ?? "mod", + actionType: overrides.actionType ?? "view", + isActive: overrides.isActive ?? true, + roleCount: overrides.roleCount ?? 0, + menuId: overrides.menuId ?? "", + menuTitle: overrides.menuTitle ?? "", + } as PermissionDetail +} + +describe("groupPermissionsByMenu", () => { + it("groups by menuId and puts the global bucket last", () => { + const perms = [ + perm({ permissionCode: "finance.master.uom.view", menuId: "m1", menuTitle: "UOM" }), + perm({ permissionCode: "finance.master.uom.create", menuId: "m1", menuTitle: "UOM" }), + perm({ permissionCode: "dashboard.view", menuId: "" }), + perm({ permissionCode: "iam.rbac.role.view", menuId: "m2", menuTitle: "Roles" }), + ] + + const groups = groupPermissionsByMenu(perms) + + expect(groups).toHaveLength(3) + expect(groups[0]).toMatchObject({ menuId: "m1", menuTitle: "UOM" }) + expect(groups[0].permissions).toHaveLength(2) + expect(groups[1]).toMatchObject({ menuId: "m2", menuTitle: "Roles" }) + // Global bucket is always last. + expect(groups[2]).toMatchObject({ menuId: null, menuTitle: GLOBAL_GROUP_TITLE }) + expect(groups[2].permissions).toHaveLength(1) + }) + + it("preserves first-seen order of named pages", () => { + const perms = [ + perm({ menuId: "b", menuTitle: "B" }), + perm({ menuId: "a", menuTitle: "A" }), + ] + const groups = groupPermissionsByMenu(perms) + expect(groups.map((g) => g.menuId)).toEqual(["b", "a"]) + }) + + it("omits the global bucket when every permission has a page", () => { + const groups = groupPermissionsByMenu([perm({ menuId: "m1", menuTitle: "UOM" })]) + expect(groups).toHaveLength(1) + expect(groups.every((g) => g.menuId !== null)).toBe(true) + }) + + it("treats whitespace-only menuId as global", () => { + const groups = groupPermissionsByMenu([perm({ menuId: " ", menuTitle: "" })]) + expect(groups).toHaveLength(1) + expect(groups[0].menuId).toBeNull() + }) + + it("falls back to menuId as title when menuTitle is missing", () => { + const groups = groupPermissionsByMenu([perm({ menuId: "m9", menuTitle: "" })]) + expect(groups[0].menuTitle).toBe("m9") + }) +}) diff --git a/src/app/(dashboard)/administrator/permissions/catalog/loading.tsx b/src/app/(dashboard)/administrator/permissions/catalog/loading.tsx new file mode 100644 index 0000000..dead41e --- /dev/null +++ b/src/app/(dashboard)/administrator/permissions/catalog/loading.tsx @@ -0,0 +1,15 @@ +export default function PermissionCatalogLoading() { + return ( +
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/administrator/permissions/catalog/page.tsx b/src/app/(dashboard)/administrator/permissions/catalog/page.tsx new file mode 100644 index 0000000..209721f --- /dev/null +++ b/src/app/(dashboard)/administrator/permissions/catalog/page.tsx @@ -0,0 +1,8 @@ +import { generateMetadata as genMeta } from "@/config/site" +import PermissionCatalogClient from "./permission-catalog-client" + +export const metadata = genMeta("Permission Catalog") + +export default function PermissionCatalogPage() { + return +} diff --git a/src/app/(dashboard)/administrator/permissions/catalog/permission-catalog-client.tsx b/src/app/(dashboard)/administrator/permissions/catalog/permission-catalog-client.tsx new file mode 100644 index 0000000..4737d74 --- /dev/null +++ b/src/app/(dashboard)/administrator/permissions/catalog/permission-catalog-client.tsx @@ -0,0 +1,106 @@ +"use client" + +import { useMemo, useState } from "react" +import Link from "next/link" +import { Loader2, ArrowLeft } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { PageHeader } from "@/components/common/page-header" +import { EmptyState } from "@/components/common/empty-state" +import { DebouncedSearchInput } from "@/components/common/debounced-search-input" + +import { useAllPermissions } from "@/hooks/iam/use-permissions" +import { groupPermissionsByMenu } from "@/lib/rbac/group-permissions" +import type { PermissionDetail } from "@/types/iam/role" + +function matches(perm: PermissionDetail, q: string): boolean { + if (!q) return true + const needle = q.toLowerCase() + return ( + perm.permissionCode.toLowerCase().includes(needle) || + perm.permissionName.toLowerCase().includes(needle) || + (perm.description?.toLowerCase().includes(needle) ?? false) || + (perm.menuTitle?.toLowerCase().includes(needle) ?? false) + ) +} + +export default function PermissionCatalogClient() { + const [search, setSearch] = useState("") + const { data: all, isLoading } = useAllPermissions() + + const groups = useMemo(() => { + const filtered = search ? all.filter((p) => matches(p, search)) : all + return groupPermissionsByMenu(filtered) + }, [all, search]) + + return ( +
+ + + + + + + {isLoading ? ( +
+ +
+ ) : groups.length === 0 ? ( + + ) : ( +
+ {groups.map((group) => ( + + + {group.menuTitle} + + {group.permissions.length} permission(s) + + + + {group.permissions.map((perm) => ( +
+
+
+ {perm.permissionName} + + {perm.actionType} + +
+

+ {perm.permissionCode} +

+ {perm.description ? ( +

{perm.description}

+ ) : null} +
+ + {perm.roleCount} role(s) + +
+ ))} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/(dashboard)/administrator/permissions/permissions-page-client.tsx b/src/app/(dashboard)/administrator/permissions/permissions-page-client.tsx index c9de349..1099722 100644 --- a/src/app/(dashboard)/administrator/permissions/permissions-page-client.tsx +++ b/src/app/(dashboard)/administrator/permissions/permissions-page-client.tsx @@ -1,7 +1,8 @@ "use client" import { useState, Suspense } from "react" -import { Plus, Loader2 } from "lucide-react" +import Link from "next/link" +import { Plus, Loader2, BookOpen } from "lucide-react" import { Card, @@ -75,7 +76,13 @@ function PermissionsPageContent() { return ( <> - + + + diff --git a/src/app/(dashboard)/administrator/users/users-page-client.tsx b/src/app/(dashboard)/administrator/users/users-page-client.tsx index f4ecd54..745c998 100644 --- a/src/app/(dashboard)/administrator/users/users-page-client.tsx +++ b/src/app/(dashboard)/administrator/users/users-page-client.tsx @@ -20,6 +20,7 @@ import { UserTable, UserPagination, UserRoleDialog, + UserPermissionDialog, } from "@/components/settings/users" import { useUsers } from "@/hooks/iam/use-users" @@ -47,6 +48,7 @@ function UsersPageContent() { const [isFormOpen, setIsFormOpen] = useState(false) const [isDeleteOpen, setIsDeleteOpen] = useState(false) const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false) + const [isPermissionDialogOpen, setIsPermissionDialogOpen] = useState(false) const [selectedUser, setSelectedUser] = useState(null) const { data, isLoading } = useUsers(filters) @@ -71,6 +73,11 @@ function UsersPageContent() { setIsRoleDialogOpen(true) } + const handleManagePermissions = (user: UserWithDetail) => { + setSelectedUser(user) + setIsPermissionDialogOpen(true) + } + const handlePageChange = (page: number) => { setFilters({ ...filters, page }) } @@ -104,6 +111,7 @@ function UsersPageContent() { onEdit={handleEdit} onDelete={handleDelete} onManageRoles={handleManageRoles} + onManagePermissions={handleManagePermissions} /> + ) } diff --git a/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx b/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx index 70daf77..fa68707 100644 --- a/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx +++ b/src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx @@ -32,6 +32,7 @@ import { useCostProductRequestCounts, useCostProductRequests } from "@/hooks/fin import { useUrlState } from "@/lib/hooks" import { getStatusDisplay } from "@/lib/ui/status-colors" import { usePermissionContext } from "@/providers/permission-provider" +import { PERMISSIONS } from "@/lib/rbac/permissions" import type { CostProductRequest, ListCostProductRequestsParams, RequestStatus } from "@/types/finance/cost-product-request" const STATUSES: RequestStatus[] = [ @@ -52,7 +53,7 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] export default function ProductRequestsPageClient() { const router = useRouter() const { hasPermission } = usePermissionContext() - const canCreate = hasPermission("finance.product.request.create") + const canCreate = hasPermission(PERMISSIONS.ProductRequests.requestCreate) const [filters, setFilters] = useUrlState({ defaultValues: defaultFilters }) const [formOpen, setFormOpen] = useState(false) diff --git a/src/app/api/v1/iam/permissions/route.ts b/src/app/api/v1/iam/permissions/route.ts index e82d339..84be892 100644 --- a/src/app/api/v1/iam/permissions/route.ts +++ b/src/app/api/v1/iam/permissions/route.ts @@ -19,6 +19,7 @@ export async function GET(request: NextRequest) { serviceName: searchParams.get("serviceName") || searchParams.get("service_name") || "", moduleName: searchParams.get("moduleName") || searchParams.get("module_name") || "", actionType: searchParams.get("actionType") || searchParams.get("action_type") || "", + menuId: searchParams.get("menuId") || searchParams.get("menu_id") || "", sortBy: searchParams.get("sortBy") || searchParams.get("sort_by") || "", sortOrder: searchParams.get("sortOrder") || searchParams.get("sort_order") || "", }, diff --git a/src/app/api/v1/iam/users/[userId]/permissions/remove/route.ts b/src/app/api/v1/iam/users/[userId]/permissions/remove/route.ts new file mode 100644 index 0000000..dc0ddf8 --- /dev/null +++ b/src/app/api/v1/iam/users/[userId]/permissions/remove/route.ts @@ -0,0 +1,33 @@ +// IAM User Permissions - Remove direct permissions from a user + +import { NextRequest, NextResponse } from "next/server" +import { getUserClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ userId: string }> } + +// POST /api/v1/iam/users/[userId]/permissions/remove +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { userId } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getUserClient() + const response = await client.removeUserPermissions({ userId, permissionIds: body.permissionIds }, metadata) + + return NextResponse.json({ base: response.base }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error removing permissions:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to remove permissions", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/v1/iam/users/[userId]/permissions/route.ts b/src/app/api/v1/iam/users/[userId]/permissions/route.ts new file mode 100644 index 0000000..0df0200 --- /dev/null +++ b/src/app/api/v1/iam/users/[userId]/permissions/route.ts @@ -0,0 +1,33 @@ +// IAM User Permissions - Assign direct permissions to a user + +import { NextRequest, NextResponse } from "next/server" +import { getUserClient, createMetadataFromRequest, isGrpcError, handleGrpcError } from "@/lib/grpc" + +type RouteContext = { params: Promise<{ userId: string }> } + +// POST /api/v1/iam/users/[userId]/permissions - Assign direct permissions +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { userId } = await context.params + const body = await request.json() + const metadata = createMetadataFromRequest(request) + const client = getUserClient() + const response = await client.assignUserPermissions({ userId, permissionIds: body.permissionIds }, metadata) + + return NextResponse.json({ base: response.base }) + } catch (error) { + if (isGrpcError(error)) return handleGrpcError(error) + console.error("Error assigning permissions:", error) + return NextResponse.json( + { + base: { + isSuccess: false, + statusCode: "500", + message: "Failed to assign permissions", + validationErrors: [], + }, + }, + { status: 500 } + ) + } +} diff --git a/src/components/iam/menus/menu-permission-dialog.tsx b/src/components/iam/menus/menu-permission-dialog.tsx index da9faef..cedd461 100644 --- a/src/components/iam/menus/menu-permission-dialog.tsx +++ b/src/components/iam/menus/menu-permission-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect } from "react" -import { Loader2, Search } from "lucide-react" +import { Loader2 } from "lucide-react" import { Dialog, @@ -12,18 +12,15 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Checkbox } from "@/components/ui/checkbox" -import { Badge } from "@/components/ui/badge" import { ScrollArea } from "@/components/ui/scroll-area" -import { Separator } from "@/components/ui/separator" import { useMenuPermissions, useAssignMenuPermissions, useRemoveMenuPermissions, } from "@/hooks/iam/use-menu" -import { usePermissions } from "@/hooks/iam/use-permissions" +import { useAllPermissions } from "@/hooks/iam/use-permissions" +import { PermissionPicker } from "@/components/settings/rbac/permission-picker" import type { NormalizedMenuWithChildren } from "@/types/iam/menu" import type { PermissionDetail } from "@/types/generated/iam/v1/role" @@ -38,48 +35,31 @@ export function MenuPermissionDialog({ onOpenChange, menu, }: MenuPermissionDialogProps) { - const [search, setSearch] = useState("") const [selected, setSelected] = useState>(new Set()) const { data: menuPerms, isLoading: isLoadingPerms } = useMenuPermissions(menu?.menuId ?? "") - const { data: allPermsData, isLoading: isLoadingAll } = usePermissions({ pageSize: 100 }) + const { data: allPermissions, isLoading: isLoadingAll } = useAllPermissions() const assign = useAssignMenuPermissions() - const remove = useRemoveMenuPermissions() + const remove = useRemoveMenuPermissions() - const allPermissions: PermissionDetail[] = (allPermsData?.data ?? []) as PermissionDetail[] const currentPermIds: string[] = ((menuPerms as PermissionDetail[]) ?? []).map((p) => p.permissionId) - // Sync selection to current menu permissions when dialog opens + // Sync selection to current menu permissions when dialog opens. useEffect(() => { - if (open && currentPermIds.length >= 0) { + if (open) { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: sync selection state when dialog opens setSelected(new Set(currentPermIds)) } }, [open, JSON.stringify(currentPermIds)]) // eslint-disable-line react-hooks/exhaustive-deps - const filtered = allPermissions.filter((p) => - !search || - p.permissionCode.toLowerCase().includes(search.toLowerCase()) || - p.permissionName.toLowerCase().includes(search.toLowerCase()) - ) - const isLoading = isLoadingPerms || isLoadingAll const isPending = assign.isPending || remove.isPending - function toggle(permId: string) { - setSelected((prev) => { - const next = new Set(prev) - if (next.has(permId)) next.delete(permId) - else next.add(permId) - return next - }) - } - async function handleSave() { if (!menu) return const currentSet = new Set(currentPermIds) - const toAdd = [...selected].filter((id) => !currentSet.has(id)) + const toAdd = [...selected].filter((id) => !currentSet.has(id)) const toRemove = currentPermIds.filter((id) => !selected.has(id)) if (toAdd.length > 0) { @@ -93,86 +73,36 @@ export function MenuPermissionDialog({ return ( - + Manage Permissions - Assign required permissions for{" "} - "{menu?.menuTitle}". Users must have at least - one of these permissions to see this menu. + Assign required permissions for "{menu?.menuTitle}". Users must + have at least one of these permissions to see this menu. Permissions are grouped by the page + they belong to. -
-
- - setSearch(e.target.value)} - /> + {isLoading ? ( +
+
- - {selected.size > 0 && ( -
- {[...selected].map((id) => { - const perm = allPermissions.find((p) => p.permissionId === id) - return perm ? ( - toggle(id)} - > - {perm.permissionCode} × - - ) : null - })} -
- )} - - - - - {isLoading ? ( -
- -
- ) : filtered.length === 0 ? ( -

- No permissions found -

- ) : ( -
- {filtered.map((perm) => ( -
toggle(perm.permissionId)} - > - toggle(perm.permissionId)} - className="mt-0.5 shrink-0" - /> -
-

- {perm.permissionCode} -

-

- {perm.permissionName} -

-
-
- ))} -
- )} + ) : ( + + -
+ )} -