Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/__tests__/rbac/group-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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")
})
})
15 changes: 15 additions & 0 deletions src/app/(dashboard)/administrator/permissions/catalog/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function PermissionCatalogLoading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-muted" />
))}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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 <PermissionCatalogClient />
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6">
<PageHeader
title="Permission Catalog"
subtitle="Browse every permission grouped by the page it belongs to, with a description of what it does."
>
<Button variant="outline" asChild>
<Link href="/administrator/permissions">
<ArrowLeft className="mr-2 h-4 w-4" /> Back to management
</Link>
</Button>
</PageHeader>

<DebouncedSearchInput
value={search}
onValueChange={setSearch}
placeholder="Search by page, permission name, code, or description…"
className="max-w-md"
/>

{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : groups.length === 0 ? (
<EmptyState title="No permissions found" description="Try a different search term." />
) : (
<div className="space-y-4">
{groups.map((group) => (
<Card key={group.menuId ?? "__global__"}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-sm font-semibold">{group.menuTitle}</CardTitle>
<span className="text-xs text-muted-foreground">
{group.permissions.length} permission(s)
</span>
</CardHeader>
<CardContent className="space-y-2">
{group.permissions.map((perm) => (
<div
key={perm.permissionId}
className="flex flex-col gap-1 border-b pb-2 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
>
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{perm.permissionName}</span>
<Badge variant="secondary" className="font-mono text-[10px] font-normal">
{perm.actionType}
</Badge>
</div>
<p className="font-mono text-xs text-muted-foreground">
{perm.permissionCode}
</p>
{perm.description ? (
<p className="text-xs text-muted-foreground">{perm.description}</p>
) : null}
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{perm.roleCount} role(s)
</span>
</div>
))}
</CardContent>
</Card>
))}
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -75,7 +76,13 @@ function PermissionsPageContent() {

return (
<>
<PageHeader title="Permission Management" subtitle="Manage permissions for role-based access control." />
<PageHeader title="Permission Management" subtitle="Manage permissions for role-based access control.">
<Button variant="outline" asChild>
<Link href="/administrator/permissions/catalog">
<BookOpen className="mr-2 h-4 w-4" /> View catalog
</Link>
</Button>
</PageHeader>

<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
Expand Down
13 changes: 13 additions & 0 deletions src/app/(dashboard)/administrator/users/users-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
UserTable,
UserPagination,
UserRoleDialog,
UserPermissionDialog,
} from "@/components/settings/users"

import { useUsers } from "@/hooks/iam/use-users"
Expand Down Expand Up @@ -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<UserWithDetail | null>(null)

const { data, isLoading } = useUsers(filters)
Expand All @@ -71,6 +73,11 @@ function UsersPageContent() {
setIsRoleDialogOpen(true)
}

const handleManagePermissions = (user: UserWithDetail) => {
setSelectedUser(user)
setIsPermissionDialogOpen(true)
}

const handlePageChange = (page: number) => {
setFilters({ ...filters, page })
}
Expand Down Expand Up @@ -104,6 +111,7 @@ function UsersPageContent() {
onEdit={handleEdit}
onDelete={handleDelete}
onManageRoles={handleManageRoles}
onManagePermissions={handleManagePermissions}
/>
<UserPagination
pagination={data?.pagination}
Expand All @@ -128,6 +136,11 @@ function UsersPageContent() {
onOpenChange={setIsRoleDialogOpen}
user={selectedUser}
/>
<UserPermissionDialog
open={isPermissionDialogOpen}
onOpenChange={setIsPermissionDialogOpen}
user={selectedUser}
/>
</>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
import {
RequestFormDialog,
RequestTable,
buildColumns,

Check warning on line 26 in src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

'buildColumns' is defined but never used
TABLE_ID,

Check warning on line 27 in src/app/(dashboard)/finance/product-requests/product-requests-page-client.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build

'TABLE_ID' is defined but never used
useRequestTableColumns,
} from "@/components/finance/cost-product-request"
import { FillTrackingDrawer } from "@/components/finance/fill-assignment"
Expand All @@ -32,6 +32,7 @@
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[] = [
Expand All @@ -52,7 +53,7 @@
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<ListCostProductRequestsParams>({ defaultValues: defaultFilters })
const [formOpen, setFormOpen] = useState(false)
Expand Down
1 change: 1 addition & 0 deletions src/app/api/v1/iam/permissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") || "",
},
Expand Down
33 changes: 33 additions & 0 deletions src/app/api/v1/iam/users/[userId]/permissions/remove/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}
33 changes: 33 additions & 0 deletions src/app/api/v1/iam/users/[userId]/permissions/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
)
}
}
Loading
Loading