@goplusvn/core 0.1.40 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/rbac/pages/role-form-page.tsx +752 -668
- package/src/rbac/pages/role-list-page.tsx +145 -0
- package/src/user/components/effective-permissions-dialog.tsx +235 -0
- package/src/user/components/users-card-view.tsx +20 -2
- package/src/user/pages/users-client-page.tsx +25 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import * as React from "react";
|
|
3
4
|
import { useMemo, useCallback, useState } from "react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
4
6
|
import type { ReactNode } from "react";
|
|
5
7
|
import { Loader2, UserX, KeyRound } from "lucide-react";
|
|
6
8
|
import {
|
|
@@ -27,6 +29,7 @@ import {
|
|
|
27
29
|
} from "../../ui";
|
|
28
30
|
|
|
29
31
|
import type { CrudPermissions } from "../../types";
|
|
32
|
+
import { cn } from "../../utils";
|
|
30
33
|
|
|
31
34
|
import { RoleStatsCards } from "../components/roles/role-stats-cards";
|
|
32
35
|
import { RoleToolbar } from "../components/roles/role-toolbar";
|
|
@@ -65,6 +68,140 @@ export interface RoleListPageProps {
|
|
|
65
68
|
importSlot?: ReactNode;
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
|
|
72
|
+
// ── 🗺️ Heatmap role × phân hệ ────────────────────────────────────────────
|
|
73
|
+
// Mỗi ô = tỉ lệ quyền đã cấp của role trong phân hệ (đậm dần theo %).
|
|
74
|
+
function parseAllowed(resourceConfig: any, actionCodes: string[]): string[] {
|
|
75
|
+
try {
|
|
76
|
+
const config =
|
|
77
|
+
typeof resourceConfig === "string"
|
|
78
|
+
? JSON.parse(resourceConfig)
|
|
79
|
+
: resourceConfig
|
|
80
|
+
if (config?.actions && Array.isArray(config.actions)) {
|
|
81
|
+
return actionCodes.filter((c) => (config.actions as string[]).includes(c))
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* fallthrough */
|
|
85
|
+
}
|
|
86
|
+
return actionCodes.filter((c) =>
|
|
87
|
+
["view", "create", "update", "delete"].includes(c)
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function RolePermissionHeatmap({
|
|
92
|
+
roles,
|
|
93
|
+
resources,
|
|
94
|
+
actions,
|
|
95
|
+
lang,
|
|
96
|
+
canUpdate,
|
|
97
|
+
}: {
|
|
98
|
+
roles: Role[]
|
|
99
|
+
resources: RoleListPageProps["resources"]
|
|
100
|
+
actions: RoleListPageProps["actions"]
|
|
101
|
+
lang: string
|
|
102
|
+
canUpdate?: boolean
|
|
103
|
+
}) {
|
|
104
|
+
const router = useRouter()
|
|
105
|
+
const actionCodes = React.useMemo(() => actions.map((a) => a.code), [actions])
|
|
106
|
+
|
|
107
|
+
// group → danh sách "action:resource" khả dụng
|
|
108
|
+
const groupCodes = React.useMemo(() => {
|
|
109
|
+
const map = new Map<string, string[]>()
|
|
110
|
+
resources.forEach((r) => {
|
|
111
|
+
const group = r.group || "Khác"
|
|
112
|
+
const allowed = parseAllowed(r.config, actionCodes)
|
|
113
|
+
const list = map.get(group) ?? []
|
|
114
|
+
allowed.forEach((a) => list.push(`${a}:${r.code}`))
|
|
115
|
+
map.set(group, list)
|
|
116
|
+
})
|
|
117
|
+
return map
|
|
118
|
+
}, [resources, actionCodes])
|
|
119
|
+
|
|
120
|
+
const groups = React.useMemo(() => [...groupCodes.keys()], [groupCodes])
|
|
121
|
+
if (roles.length === 0 || groups.length === 0) return null
|
|
122
|
+
|
|
123
|
+
const cellFor = (role: Role, group: string) => {
|
|
124
|
+
const codes = groupCodes.get(group) ?? []
|
|
125
|
+
const granted = codes.filter((c) => role.permissions?.includes(c)).length
|
|
126
|
+
return { granted, total: codes.length }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const shade = (ratio: number) =>
|
|
130
|
+
ratio === 0
|
|
131
|
+
? "bg-muted/60"
|
|
132
|
+
: ratio < 0.34
|
|
133
|
+
? "bg-primary/20"
|
|
134
|
+
: ratio < 0.67
|
|
135
|
+
? "bg-primary/45"
|
|
136
|
+
: ratio < 1
|
|
137
|
+
? "bg-primary/70"
|
|
138
|
+
: "bg-primary"
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
|
142
|
+
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
|
|
143
|
+
<h3 className="text-sm font-semibold text-foreground">
|
|
144
|
+
Bản đồ quyền theo phân hệ
|
|
145
|
+
</h3>
|
|
146
|
+
<span className="text-[11px] text-muted-foreground">
|
|
147
|
+
ô càng đậm = càng nhiều quyền · bấm ô để mở vai trò
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="overflow-x-auto">
|
|
151
|
+
<table className="w-full border-collapse text-xs">
|
|
152
|
+
<thead>
|
|
153
|
+
<tr>
|
|
154
|
+
<th className="sticky left-0 z-10 bg-card px-4 py-1.5 text-left font-medium text-muted-foreground">
|
|
155
|
+
Vai trò
|
|
156
|
+
</th>
|
|
157
|
+
{groups.map((g) => (
|
|
158
|
+
<th
|
|
159
|
+
key={g}
|
|
160
|
+
className="min-w-16 px-1 py-1.5 text-center font-medium text-muted-foreground"
|
|
161
|
+
title={g}
|
|
162
|
+
>
|
|
163
|
+
<span className="block max-w-20 truncate">{g}</span>
|
|
164
|
+
</th>
|
|
165
|
+
))}
|
|
166
|
+
</tr>
|
|
167
|
+
</thead>
|
|
168
|
+
<tbody className="divide-y divide-border/50">
|
|
169
|
+
{roles.map((role) => (
|
|
170
|
+
<tr key={role.id} className="hover:bg-muted/30">
|
|
171
|
+
<td className="sticky left-0 z-10 max-w-44 truncate bg-card px-4 py-1 font-medium text-foreground">
|
|
172
|
+
{role.name}
|
|
173
|
+
</td>
|
|
174
|
+
{groups.map((g) => {
|
|
175
|
+
const { granted, total } = cellFor(role, g)
|
|
176
|
+
const ratio = total > 0 ? granted / total : 0
|
|
177
|
+
return (
|
|
178
|
+
<td key={g} className="px-1 py-1 text-center">
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
disabled={!canUpdate}
|
|
182
|
+
onClick={() =>
|
|
183
|
+
router.push(`/${lang}/roles/${role.id}/edit`)
|
|
184
|
+
}
|
|
185
|
+
title={`${role.name} · ${g}: ${granted}/${total} quyền`}
|
|
186
|
+
className={cn(
|
|
187
|
+
"mx-auto block h-5 w-10 rounded",
|
|
188
|
+
shade(ratio),
|
|
189
|
+
canUpdate && "cursor-pointer hover:ring-2 hover:ring-primary/40"
|
|
190
|
+
)}
|
|
191
|
+
aria-label={`${role.name} — ${g}: ${granted}/${total} quyền`}
|
|
192
|
+
/>
|
|
193
|
+
</td>
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
196
|
+
</tr>
|
|
197
|
+
))}
|
|
198
|
+
</tbody>
|
|
199
|
+
</table>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
68
205
|
export function RoleListPage({
|
|
69
206
|
dictionary,
|
|
70
207
|
permissions,
|
|
@@ -233,6 +370,14 @@ export function RoleListPage({
|
|
|
233
370
|
importSlot={importSlot}
|
|
234
371
|
/>
|
|
235
372
|
|
|
373
|
+
<RolePermissionHeatmap
|
|
374
|
+
roles={roles}
|
|
375
|
+
resources={resources}
|
|
376
|
+
actions={actions}
|
|
377
|
+
lang={lang}
|
|
378
|
+
canUpdate={canUpdate}
|
|
379
|
+
/>
|
|
380
|
+
|
|
236
381
|
{/* Roles Grid */}
|
|
237
382
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
238
383
|
{roles.length === 0 && !isLoading ? (
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// 👤 QUYỀN HIỆU LỰC — user mang nhiều vai trò thì rốt cuộc thấy gì?
|
|
4
|
+
// Hợp nhất (union) quyền từ mọi vai trò của user rồi trả lời trực quan:
|
|
5
|
+
// - Sidebar preview: menu nào hiện với người này (từ menuTree).
|
|
6
|
+
// - Phạm vi dữ liệu: chi nhánh được gán hay tất cả (view-all-branches).
|
|
7
|
+
// - Quyền nhạy cảm đang có (actionMeta.flow "Nhạy cảm"/"Phê duyệt").
|
|
8
|
+
// Nguồn: GET /api/roles?pageSize=100 (permissions từng role) — match theo
|
|
9
|
+
// tên vai trò gắn trên user.
|
|
10
|
+
import * as React from "react"
|
|
11
|
+
import {
|
|
12
|
+
Badge,
|
|
13
|
+
Dialog,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
DynamicIcon,
|
|
18
|
+
} from "../../ui"
|
|
19
|
+
import { Building2, KeyRound, ShieldAlert } from "lucide-react"
|
|
20
|
+
|
|
21
|
+
export interface EffectiveMenuTreeSection {
|
|
22
|
+
title: string
|
|
23
|
+
icon?: string
|
|
24
|
+
items: Array<{
|
|
25
|
+
title: string
|
|
26
|
+
href?: string
|
|
27
|
+
icon?: string
|
|
28
|
+
resource: string
|
|
29
|
+
note?: string
|
|
30
|
+
}>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EffectivePermissionsDialogProps {
|
|
34
|
+
open: boolean
|
|
35
|
+
onOpenChange: (open: boolean) => void
|
|
36
|
+
user: {
|
|
37
|
+
id?: string
|
|
38
|
+
name?: string | null
|
|
39
|
+
email?: string | null
|
|
40
|
+
/** Tên các vai trò gắn trên user (như chip hiển thị ở card). */
|
|
41
|
+
roles?: string[]
|
|
42
|
+
roleNames?: string[]
|
|
43
|
+
} | null
|
|
44
|
+
menuTree?: EffectiveMenuTreeSection[]
|
|
45
|
+
actionMeta?: Record<
|
|
46
|
+
string,
|
|
47
|
+
{ label?: string; flow?: string; description?: string }
|
|
48
|
+
>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RoleLite {
|
|
52
|
+
id: string
|
|
53
|
+
name: string
|
|
54
|
+
code: string
|
|
55
|
+
permissions: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function EffectivePermissionsDialog({
|
|
59
|
+
open,
|
|
60
|
+
onOpenChange,
|
|
61
|
+
user,
|
|
62
|
+
menuTree,
|
|
63
|
+
actionMeta,
|
|
64
|
+
}: EffectivePermissionsDialogProps) {
|
|
65
|
+
const [roles, setRoles] = React.useState<RoleLite[] | null>(null)
|
|
66
|
+
const [loading, setLoading] = React.useState(false)
|
|
67
|
+
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
if (!open || roles) return
|
|
70
|
+
setLoading(true)
|
|
71
|
+
fetch("/api/roles?pageSize=200")
|
|
72
|
+
.then((r) => r.json())
|
|
73
|
+
.then((data) => setRoles(data.items ?? []))
|
|
74
|
+
.catch(() => setRoles([]))
|
|
75
|
+
.finally(() => setLoading(false))
|
|
76
|
+
}, [open, roles])
|
|
77
|
+
|
|
78
|
+
const roleNameList = user?.roles ?? user?.roleNames ?? []
|
|
79
|
+
const userRoleNames = React.useMemo(
|
|
80
|
+
() => new Set(roleNameList.map((r) => r.toLowerCase())),
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
[user]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const matchedRoles = React.useMemo(
|
|
86
|
+
() =>
|
|
87
|
+
(roles ?? []).filter(
|
|
88
|
+
(r) =>
|
|
89
|
+
userRoleNames.has(r.name.toLowerCase()) ||
|
|
90
|
+
userRoleNames.has(r.code.toLowerCase())
|
|
91
|
+
),
|
|
92
|
+
[roles, userRoleNames]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
/** Union mọi quyền từ các vai trò. */
|
|
96
|
+
const effective = React.useMemo(() => {
|
|
97
|
+
const set = new Set<string>()
|
|
98
|
+
matchedRoles.forEach((r) => r.permissions?.forEach((p) => set.add(p)))
|
|
99
|
+
return set
|
|
100
|
+
}, [matchedRoles])
|
|
101
|
+
|
|
102
|
+
const has = React.useCallback(
|
|
103
|
+
(resource: string, action: string) => effective.has(`${action}:${resource}`),
|
|
104
|
+
[effective]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const isSuperAdmin = userRoleNames.has("super_admin")
|
|
108
|
+
|
|
109
|
+
const visibleSections = React.useMemo(() => {
|
|
110
|
+
if (!menuTree) return []
|
|
111
|
+
return menuTree
|
|
112
|
+
.map((section) => ({
|
|
113
|
+
...section,
|
|
114
|
+
items: section.items.filter(
|
|
115
|
+
(i) => !i.note && (isSuperAdmin || has(i.resource, "view"))
|
|
116
|
+
),
|
|
117
|
+
}))
|
|
118
|
+
.filter((s) => s.items.length > 0)
|
|
119
|
+
}, [menuTree, has, isSuperAdmin])
|
|
120
|
+
|
|
121
|
+
/** Quyền thuộc luồng nhạy cảm / phê duyệt đang bật. */
|
|
122
|
+
const sensitiveGrants = React.useMemo(() => {
|
|
123
|
+
if (!actionMeta) return []
|
|
124
|
+
const list: Array<{ action: string; resource: string; label: string }> = []
|
|
125
|
+
effective.forEach((perm) => {
|
|
126
|
+
const [action, resource] = perm.split(":")
|
|
127
|
+
const meta = actionMeta[action]
|
|
128
|
+
if (meta?.flow === "Nhạy cảm" || meta?.flow === "Phê duyệt") {
|
|
129
|
+
list.push({ action, resource, label: meta.label || action })
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
return list
|
|
133
|
+
}, [effective, actionMeta])
|
|
134
|
+
|
|
135
|
+
const branchAll =
|
|
136
|
+
isSuperAdmin ||
|
|
137
|
+
[...effective].some((p) => p.startsWith("view-all-branches:"))
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
141
|
+
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
|
142
|
+
<DialogHeader>
|
|
143
|
+
<DialogTitle className="flex items-center gap-2 text-base">
|
|
144
|
+
<KeyRound className="h-4 w-4 text-primary" />
|
|
145
|
+
Quyền hiệu lực — {user?.name || user?.email || ""}
|
|
146
|
+
</DialogTitle>
|
|
147
|
+
</DialogHeader>
|
|
148
|
+
|
|
149
|
+
{/* Vai trò nguồn */}
|
|
150
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
151
|
+
<span className="text-xs text-muted-foreground">Từ vai trò:</span>
|
|
152
|
+
{roleNameList.map((r) => (
|
|
153
|
+
<Badge key={r} variant="outline" className="text-[11px]">
|
|
154
|
+
{r}
|
|
155
|
+
</Badge>
|
|
156
|
+
))}
|
|
157
|
+
{isSuperAdmin && (
|
|
158
|
+
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">
|
|
159
|
+
— Super Admin: toàn quyền hệ thống
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Phạm vi dữ liệu */}
|
|
165
|
+
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 text-xs">
|
|
166
|
+
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
167
|
+
<span>
|
|
168
|
+
Phạm vi dữ liệu:{" "}
|
|
169
|
+
<b>{branchAll ? "tất cả chi nhánh" : "chi nhánh được gán"}</b>
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{/* Quyền nhạy cảm */}
|
|
174
|
+
{sensitiveGrants.length > 0 && (
|
|
175
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-900 dark:bg-amber-950/40">
|
|
176
|
+
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-400">
|
|
177
|
+
<ShieldAlert className="h-3.5 w-3.5" />
|
|
178
|
+
Quyền nhạy cảm đang có
|
|
179
|
+
</p>
|
|
180
|
+
<div className="flex flex-wrap gap-1">
|
|
181
|
+
{sensitiveGrants.map((g) => (
|
|
182
|
+
<span
|
|
183
|
+
key={`${g.action}:${g.resource}`}
|
|
184
|
+
className="rounded bg-amber-100 px-1.5 py-0.5 text-[11px] text-amber-800 dark:bg-amber-900/60 dark:text-amber-300"
|
|
185
|
+
>
|
|
186
|
+
{g.label}
|
|
187
|
+
</span>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{/* Sidebar preview */}
|
|
194
|
+
<div className="overflow-hidden rounded-lg border border-border">
|
|
195
|
+
<div className="border-b border-white/10 bg-sidebar px-3 py-1.5">
|
|
196
|
+
<p className="text-[11px] font-semibold text-primary-foreground">
|
|
197
|
+
Menu người này nhìn thấy
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
<div className="max-h-72 overflow-y-auto bg-sidebar px-2 py-2 [scrollbar-width:thin]">
|
|
201
|
+
{loading ? (
|
|
202
|
+
<p className="px-2 py-4 text-center text-[11px] text-primary-foreground/50">
|
|
203
|
+
Đang tải...
|
|
204
|
+
</p>
|
|
205
|
+
) : visibleSections.length === 0 ? (
|
|
206
|
+
<p className="px-2 py-4 text-center text-[11px] text-primary-foreground/50">
|
|
207
|
+
Không thấy menu nào.
|
|
208
|
+
</p>
|
|
209
|
+
) : (
|
|
210
|
+
visibleSections.map((sec) => (
|
|
211
|
+
<div key={sec.title} className="mb-1.5">
|
|
212
|
+
<p className="px-2 py-1 text-[9px] font-semibold uppercase tracking-wider text-primary-foreground/40">
|
|
213
|
+
{sec.title}
|
|
214
|
+
</p>
|
|
215
|
+
{sec.items.map((i) => (
|
|
216
|
+
<div
|
|
217
|
+
key={`${i.resource}:${i.title}`}
|
|
218
|
+
className="flex items-center gap-2 rounded-md px-2 py-1 text-primary-foreground/85"
|
|
219
|
+
>
|
|
220
|
+
<DynamicIcon
|
|
221
|
+
name={i.icon as any}
|
|
222
|
+
className="h-3.5 w-3.5 shrink-0 opacity-70"
|
|
223
|
+
/>
|
|
224
|
+
<span className="truncate text-[11px]">{i.title}</span>
|
|
225
|
+
</div>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
))
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
</DialogContent>
|
|
233
|
+
</Dialog>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -15,11 +15,13 @@ import {
|
|
|
15
15
|
AvatarImage,
|
|
16
16
|
} from "../../ui";
|
|
17
17
|
import { cn } from "../../utils";
|
|
18
|
-
import { Mail, Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Store, ShieldCheck, Hash, Building2 } from "lucide-react";
|
|
18
|
+
import { Mail, Phone, MoreHorizontal, Eye, Edit, User, Briefcase, Store, ShieldCheck, Hash, Building2 , KeyRound} from "lucide-react";
|
|
19
19
|
|
|
20
20
|
interface UsersCardViewProps {
|
|
21
21
|
data: any[];
|
|
22
22
|
onSelect?: (user: any) => void;
|
|
23
|
+
/** Mở dialog "Quyền hiệu lực" cho user (additive). */
|
|
24
|
+
onViewPermissions?: (user: any) => void;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
// Professional Corporate Palette
|
|
@@ -53,7 +55,11 @@ const getAvatarColor = (name: string | null) => {
|
|
|
53
55
|
return AVATAR_PALETTE[charCode % AVATAR_PALETTE.length];
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
export function UsersCardView({
|
|
58
|
+
export function UsersCardView({
|
|
59
|
+
data,
|
|
60
|
+
onSelect,
|
|
61
|
+
onViewPermissions,
|
|
62
|
+
}: UsersCardViewProps) {
|
|
57
63
|
if (!data.length) {
|
|
58
64
|
return (
|
|
59
65
|
<div className="flex flex-col items-center justify-center min-h-[400px] border border-[#e2e8f0] rounded-xl bg-white p-8 text-center">
|
|
@@ -192,6 +198,18 @@ export function UsersCardView({ data, onSelect }: UsersCardViewProps) {
|
|
|
192
198
|
<Edit className="mr-2 size-3.5" />
|
|
193
199
|
Chỉnh sửa
|
|
194
200
|
</DropdownMenuItem>
|
|
201
|
+
{onViewPermissions && (
|
|
202
|
+
<DropdownMenuItem
|
|
203
|
+
onSelect={(e) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
setTimeout(() => onViewPermissions(user), 0);
|
|
206
|
+
}}
|
|
207
|
+
className="rounded-lg text-[13px] cursor-pointer"
|
|
208
|
+
>
|
|
209
|
+
<KeyRound className="size-3.5 mr-2 text-muted-foreground" />
|
|
210
|
+
Quyền hiệu lực
|
|
211
|
+
</DropdownMenuItem>
|
|
212
|
+
)}
|
|
195
213
|
</DropdownMenuContent>
|
|
196
214
|
</DropdownMenu>
|
|
197
215
|
</div>
|
|
@@ -21,6 +21,10 @@ import { UserToolbar } from "../components/user-toolbar";
|
|
|
21
21
|
import { UserStats } from "../components/user-stats";
|
|
22
22
|
import { UsersCardView } from "../components/users-card-view";
|
|
23
23
|
import { UnifiedProfileDialog } from "../components/unified-profile-dialog";
|
|
24
|
+
import {
|
|
25
|
+
EffectivePermissionsDialog,
|
|
26
|
+
type EffectiveMenuTreeSection,
|
|
27
|
+
} from "../components/effective-permissions-dialog";
|
|
24
28
|
|
|
25
29
|
interface UserStats {
|
|
26
30
|
totalUsers: number;
|
|
@@ -39,6 +43,9 @@ interface UsersClientPageProps {
|
|
|
39
43
|
stats: UserStats;
|
|
40
44
|
onAssignRoles?: (userId: string, roleCodes: string[]) => Promise<any>;
|
|
41
45
|
onResetPassword?: (userId: string, password: string) => Promise<any>;
|
|
46
|
+
/** Cây menu + meta action cho dialog "Quyền hiệu lực" (additive). */
|
|
47
|
+
menuTree?: EffectiveMenuTreeSection[];
|
|
48
|
+
actionMeta?: Record<string, { label?: string; flow?: string; description?: string }>;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export function UsersClientPage({
|
|
@@ -50,7 +57,10 @@ export function UsersClientPage({
|
|
|
50
57
|
stats,
|
|
51
58
|
onAssignRoles,
|
|
52
59
|
onResetPassword,
|
|
60
|
+
menuTree,
|
|
61
|
+
actionMeta,
|
|
53
62
|
}: UsersClientPageProps) {
|
|
63
|
+
const [permUser, setPermUser] = useState<any | null>(null);
|
|
54
64
|
const router = useRouter();
|
|
55
65
|
const pathname = usePathname();
|
|
56
66
|
|
|
@@ -207,10 +217,24 @@ export function UsersClientPage({
|
|
|
207
217
|
/>
|
|
208
218
|
</div>
|
|
209
219
|
) : (
|
|
210
|
-
<UsersCardView
|
|
220
|
+
<UsersCardView
|
|
221
|
+
data={initialData}
|
|
222
|
+
onSelect={handleSelectUser}
|
|
223
|
+
onViewPermissions={menuTree ? setPermUser : undefined}
|
|
224
|
+
/>
|
|
211
225
|
)}
|
|
212
226
|
</div>
|
|
213
227
|
|
|
228
|
+
{menuTree && (
|
|
229
|
+
<EffectivePermissionsDialog
|
|
230
|
+
open={!!permUser}
|
|
231
|
+
onOpenChange={(o) => !o && setPermUser(null)}
|
|
232
|
+
user={permUser}
|
|
233
|
+
menuTree={menuTree}
|
|
234
|
+
actionMeta={actionMeta}
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
|
|
214
238
|
{/* Create User Dialog */}
|
|
215
239
|
<UnifiedProfileDialog
|
|
216
240
|
open={userFormOpen}
|