@goplusvn/core 0.1.41 → 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 +191 -1
- 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
package/package.json
CHANGED
|
@@ -37,6 +37,7 @@ import { toast } from "sonner"
|
|
|
37
37
|
import {
|
|
38
38
|
ArrowLeft,
|
|
39
39
|
Building2,
|
|
40
|
+
History,
|
|
40
41
|
Search,
|
|
41
42
|
ChevronDown,
|
|
42
43
|
ChevronRight,
|
|
@@ -142,6 +143,8 @@ export interface RoleFormPageProps {
|
|
|
142
143
|
resourcePages?: Record<string, string>
|
|
143
144
|
/** Cây menu thật — không truyền thì tự dựng từ group của resources. */
|
|
144
145
|
menuTree?: MenuTreeSection[]
|
|
146
|
+
/** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
|
|
147
|
+
historyEndpoint?: string
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
export function RoleFormPage({
|
|
@@ -157,6 +160,7 @@ export function RoleFormPage({
|
|
|
157
160
|
actionMeta,
|
|
158
161
|
resourcePages,
|
|
159
162
|
menuTree,
|
|
163
|
+
historyEndpoint,
|
|
160
164
|
}: RoleFormPageProps) {
|
|
161
165
|
const router = useRouter()
|
|
162
166
|
|
|
@@ -168,6 +172,16 @@ export function RoleFormPage({
|
|
|
168
172
|
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
169
173
|
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
|
170
174
|
const [searchTerm, setSearchTerm] = React.useState("")
|
|
175
|
+
const [historyOpen, setHistoryOpen] = React.useState(false)
|
|
176
|
+
const [history, setHistory] = React.useState<any[] | null>(null)
|
|
177
|
+
|
|
178
|
+
React.useEffect(() => {
|
|
179
|
+
if (!historyOpen || history || !historyEndpoint) return
|
|
180
|
+
fetch(historyEndpoint)
|
|
181
|
+
.then((r) => r.json())
|
|
182
|
+
.then((d) => setHistory(d.items ?? []))
|
|
183
|
+
.catch(() => setHistory([]))
|
|
184
|
+
}, [historyOpen, history, historyEndpoint])
|
|
171
185
|
|
|
172
186
|
const [formData, setFormData] = React.useState({
|
|
173
187
|
name: initialData?.name || "",
|
|
@@ -525,6 +539,15 @@ export function RoleFormPage({
|
|
|
525
539
|
})
|
|
526
540
|
}, [offMenuResources, searching, searchTerm, resourceInfo, pageActions, actionName])
|
|
527
541
|
|
|
542
|
+
const permLabel = React.useCallback(
|
|
543
|
+
(perm: string) => {
|
|
544
|
+
const [action, resource] = perm.split(":")
|
|
545
|
+
const rName = resourceInfo.get(resource)?.name || resource
|
|
546
|
+
return `${actionName(action)} · ${rName}`
|
|
547
|
+
},
|
|
548
|
+
[resourceInfo, actionName]
|
|
549
|
+
)
|
|
550
|
+
|
|
528
551
|
// Render 1 dòng trang menu + hàng thao tác của nó
|
|
529
552
|
const renderPageRow = (item: {
|
|
530
553
|
title: string
|
|
@@ -754,7 +777,8 @@ export function RoleFormPage({
|
|
|
754
777
|
</Button>
|
|
755
778
|
</div>
|
|
756
779
|
|
|
757
|
-
<div className="mt-3
|
|
780
|
+
<div className="mt-3 flex items-start gap-3 lg:mx-3">
|
|
781
|
+
<div className="min-w-0 flex-1 overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
|
758
782
|
{/* ===== 2. Toolbar: mẫu + phạm vi chi nhánh + đếm ===== */}
|
|
759
783
|
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2 sm:px-4">
|
|
760
784
|
<div className="relative min-w-[150px] flex-1 sm:max-w-64">
|
|
@@ -929,6 +953,172 @@ export function RoleFormPage({
|
|
|
929
953
|
</section>
|
|
930
954
|
)}
|
|
931
955
|
</div>
|
|
956
|
+
|
|
957
|
+
{/* ===== 🪞 PREVIEW SIDEBAR SỐNG — nhân viên mang vai trò này sẽ thấy ===== */}
|
|
958
|
+
<aside className="sticky top-4 hidden w-60 shrink-0 overflow-hidden rounded-xl border border-border shadow-sm xl:block">
|
|
959
|
+
<div className="border-b border-white/10 bg-sidebar px-3 py-2">
|
|
960
|
+
<p className="text-xs font-semibold text-primary-foreground">
|
|
961
|
+
Nhân viên sẽ thấy
|
|
962
|
+
</p>
|
|
963
|
+
<p className="text-[10px] text-primary-foreground/60">
|
|
964
|
+
Sidebar cập nhật theo từng ô tick
|
|
965
|
+
</p>
|
|
966
|
+
</div>
|
|
967
|
+
<div className="max-h-[70vh] overflow-y-auto bg-sidebar px-2 py-2 [scrollbar-width:thin]">
|
|
968
|
+
{(() => {
|
|
969
|
+
const visibleSections = tree
|
|
970
|
+
.map((section) => ({
|
|
971
|
+
...section,
|
|
972
|
+
items: section.items.filter(
|
|
973
|
+
(i) => !i.note && has(i.resource, "view")
|
|
974
|
+
),
|
|
975
|
+
}))
|
|
976
|
+
.filter((sec) => sec.items.length > 0)
|
|
977
|
+
if (visibleSections.length === 0) {
|
|
978
|
+
return (
|
|
979
|
+
<p className="px-2 py-6 text-center text-[11px] text-primary-foreground/50">
|
|
980
|
+
Chưa có menu nào —
|
|
981
|
+
<br />
|
|
982
|
+
vai trò này không thấy gì trên sidebar.
|
|
983
|
+
</p>
|
|
984
|
+
)
|
|
985
|
+
}
|
|
986
|
+
return visibleSections.map((sec) => (
|
|
987
|
+
<div key={sec.title} className="mb-1.5">
|
|
988
|
+
<p className="px-2 py-1 text-[9px] font-semibold uppercase tracking-wider text-primary-foreground/40">
|
|
989
|
+
{sec.title}
|
|
990
|
+
</p>
|
|
991
|
+
{sec.items.map((i) => (
|
|
992
|
+
<div
|
|
993
|
+
key={`${i.resource}:${i.title}`}
|
|
994
|
+
className="flex items-center gap-2 rounded-md px-2 py-1 text-primary-foreground/85"
|
|
995
|
+
>
|
|
996
|
+
<DynamicIcon
|
|
997
|
+
name={i.icon as any}
|
|
998
|
+
className="h-3.5 w-3.5 shrink-0 opacity-70"
|
|
999
|
+
/>
|
|
1000
|
+
<span className="truncate text-[11px]">{i.title}</span>
|
|
1001
|
+
</div>
|
|
1002
|
+
))}
|
|
1003
|
+
</div>
|
|
1004
|
+
))
|
|
1005
|
+
})()}
|
|
1006
|
+
</div>
|
|
1007
|
+
{branchScopeResources.length > 0 && (
|
|
1008
|
+
<div className="border-t border-white/10 bg-sidebar px-3 py-1.5 text-[10px] text-primary-foreground/60">
|
|
1009
|
+
Dữ liệu:{" "}
|
|
1010
|
+
{branchScope === "all"
|
|
1011
|
+
? "tất cả chi nhánh"
|
|
1012
|
+
: "chi nhánh được gán"}
|
|
1013
|
+
</div>
|
|
1014
|
+
)}
|
|
1015
|
+
</aside>
|
|
1016
|
+
</div>
|
|
1017
|
+
|
|
1018
|
+
{/* ===== 🕓 Nhật ký thay đổi quyền ===== */}
|
|
1019
|
+
{mode === "edit" && historyEndpoint && (
|
|
1020
|
+
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
1021
|
+
<button
|
|
1022
|
+
type="button"
|
|
1023
|
+
onClick={() => setHistoryOpen((o) => !o)}
|
|
1024
|
+
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-muted/30"
|
|
1025
|
+
>
|
|
1026
|
+
<History className="h-4 w-4 text-muted-foreground" />
|
|
1027
|
+
<span className="text-sm font-semibold text-foreground">
|
|
1028
|
+
Nhật ký thay đổi quyền
|
|
1029
|
+
</span>
|
|
1030
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1031
|
+
ai đổi gì, lúc nào
|
|
1032
|
+
</span>
|
|
1033
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
1034
|
+
{historyOpen ? "Thu gọn" : "Xem"}
|
|
1035
|
+
</span>
|
|
1036
|
+
</button>
|
|
1037
|
+
{historyOpen && (
|
|
1038
|
+
<div className="border-t border-border">
|
|
1039
|
+
{history === null ? (
|
|
1040
|
+
<p className="px-4 py-4 text-center text-xs text-muted-foreground">
|
|
1041
|
+
Đang tải...
|
|
1042
|
+
</p>
|
|
1043
|
+
) : history.length === 0 ? (
|
|
1044
|
+
<p className="px-4 py-4 text-center text-xs text-muted-foreground">
|
|
1045
|
+
Chưa có thay đổi nào được ghi nhận.
|
|
1046
|
+
</p>
|
|
1047
|
+
) : (
|
|
1048
|
+
<ul className="divide-y divide-border/60">
|
|
1049
|
+
{history.map((e) => (
|
|
1050
|
+
<li key={e.id} className="px-4 py-2 text-xs">
|
|
1051
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
1052
|
+
<span className="tabular-nums text-muted-foreground">
|
|
1053
|
+
{new Date(e.at).toLocaleString("vi-VN", {
|
|
1054
|
+
hour: "2-digit",
|
|
1055
|
+
minute: "2-digit",
|
|
1056
|
+
day: "2-digit",
|
|
1057
|
+
month: "2-digit",
|
|
1058
|
+
year: "numeric",
|
|
1059
|
+
})}
|
|
1060
|
+
</span>
|
|
1061
|
+
{e.by && (
|
|
1062
|
+
<span className="font-medium text-foreground">
|
|
1063
|
+
{e.by}
|
|
1064
|
+
</span>
|
|
1065
|
+
)}
|
|
1066
|
+
{e.action === "create" && (
|
|
1067
|
+
<span className="text-muted-foreground">
|
|
1068
|
+
tạo vai trò
|
|
1069
|
+
</span>
|
|
1070
|
+
)}
|
|
1071
|
+
{e.nameChange && (
|
|
1072
|
+
<span className="text-muted-foreground">
|
|
1073
|
+
đổi tên "{e.nameChange.from}" → "{e.nameChange.to}"
|
|
1074
|
+
</span>
|
|
1075
|
+
)}
|
|
1076
|
+
{e.statusChange && (
|
|
1077
|
+
<span className="text-muted-foreground">
|
|
1078
|
+
{e.statusChange.to === "active"
|
|
1079
|
+
? "mở khóa vai trò"
|
|
1080
|
+
: "khóa vai trò"}
|
|
1081
|
+
</span>
|
|
1082
|
+
)}
|
|
1083
|
+
</div>
|
|
1084
|
+
{(e.added?.length > 0 || e.removed?.length > 0) && (
|
|
1085
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
1086
|
+
{e.added.slice(0, 8).map((p: string) => (
|
|
1087
|
+
<span
|
|
1088
|
+
key={p}
|
|
1089
|
+
className="rounded bg-emerald-50 px-1.5 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
|
1090
|
+
>
|
|
1091
|
+
+{permLabel(p)}
|
|
1092
|
+
</span>
|
|
1093
|
+
))}
|
|
1094
|
+
{e.added.length > 8 && (
|
|
1095
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1096
|
+
+{e.added.length - 8} quyền khác
|
|
1097
|
+
</span>
|
|
1098
|
+
)}
|
|
1099
|
+
{e.removed.slice(0, 8).map((p: string) => (
|
|
1100
|
+
<span
|
|
1101
|
+
key={p}
|
|
1102
|
+
className="rounded bg-rose-50 px-1.5 py-0.5 text-[11px] text-rose-700 line-through dark:bg-rose-950 dark:text-rose-400"
|
|
1103
|
+
>
|
|
1104
|
+
{permLabel(p)}
|
|
1105
|
+
</span>
|
|
1106
|
+
))}
|
|
1107
|
+
{e.removed.length > 8 && (
|
|
1108
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1109
|
+
−{e.removed.length - 8} quyền khác
|
|
1110
|
+
</span>
|
|
1111
|
+
)}
|
|
1112
|
+
</div>
|
|
1113
|
+
)}
|
|
1114
|
+
</li>
|
|
1115
|
+
))}
|
|
1116
|
+
</ul>
|
|
1117
|
+
)}
|
|
1118
|
+
</div>
|
|
1119
|
+
)}
|
|
1120
|
+
</div>
|
|
1121
|
+
)}
|
|
932
1122
|
</div>
|
|
933
1123
|
)
|
|
934
1124
|
}
|
|
@@ -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}
|