@goplusvn/core 0.1.70 → 0.1.72
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/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- package/src/crud/components/crud-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/guardrails/types.ts +17 -8
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/types/index.ts +75 -10
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- package/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/AGENTS.md +1 -1
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- package/templates/starter-app/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
- package/templates/starter-app/src/app/api/users/route.ts +141 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/globals.css +0 -1
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +77 -4
- package/templates/starter-app/src/data/navigations.ts +60 -1
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +25 -0
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -10
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +16 -7
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +49 -0
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +10 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Trang QUẢN TRỊ "Quản lý phiên đăng nhập" (Better Auth, session DB-backed) —
|
|
4
|
+
// admin xem/thu hồi phiên của MỌI user. Promoted từ vinhhoa. Gate user:view ở
|
|
5
|
+
// page + API (app tự gác). Data: props `apiUrl` (mặc định
|
|
6
|
+
// /api/security/sessions) — app cung cấp 3 endpoint:
|
|
7
|
+
// GET {apiUrl}?search&page&pageSize → {items,total,page,pageSize}
|
|
8
|
+
// DELETE {apiUrl}/{id} → thu hồi 1 phiên
|
|
9
|
+
// POST {apiUrl}/revoke-user {userId} → đăng xuất mọi thiết bị của 1 user
|
|
10
|
+
// Impersonation ("đăng nhập thay") là tính năng app-specific (cần authClient
|
|
11
|
+
// của app) — core KHÔNG ship; app nào cần thì tự bọc thêm nút quanh trang này.
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
13
|
+
import { toast } from "sonner"
|
|
14
|
+
import {
|
|
15
|
+
ChevronLeft,
|
|
16
|
+
ChevronRight,
|
|
17
|
+
ChevronsLeft,
|
|
18
|
+
ChevronsRight,
|
|
19
|
+
Globe,
|
|
20
|
+
Monitor,
|
|
21
|
+
MonitorSmartphone,
|
|
22
|
+
RefreshCw,
|
|
23
|
+
Search,
|
|
24
|
+
Smartphone,
|
|
25
|
+
Tablet,
|
|
26
|
+
Trash2,
|
|
27
|
+
UserX,
|
|
28
|
+
X,
|
|
29
|
+
} from "lucide-react"
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
Badge,
|
|
33
|
+
Button,
|
|
34
|
+
Select,
|
|
35
|
+
SelectContent,
|
|
36
|
+
SelectItem,
|
|
37
|
+
SelectTrigger,
|
|
38
|
+
SelectValue,
|
|
39
|
+
Skeleton,
|
|
40
|
+
} from "../../ui/primitives"
|
|
41
|
+
import { ConfirmDialog } from "../../ui/shared/confirm-dialog"
|
|
42
|
+
import { cn } from "../../utils"
|
|
43
|
+
|
|
44
|
+
interface SessionRow {
|
|
45
|
+
id: string
|
|
46
|
+
userId: string
|
|
47
|
+
userName: string | null
|
|
48
|
+
userEmail: string | null
|
|
49
|
+
ipAddress: string | null
|
|
50
|
+
userAgent: string | null
|
|
51
|
+
createdAt: string
|
|
52
|
+
updatedAt: string
|
|
53
|
+
expiresAt: string
|
|
54
|
+
isCurrent: boolean
|
|
55
|
+
isMine: boolean
|
|
56
|
+
/** Plugin admin của Better Auth — app không dùng thì API cứ trả null. */
|
|
57
|
+
impersonatedBy?: string | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Phân tích User-Agent thô → trình duyệt + hệ điều hành + loại máy ---
|
|
61
|
+
function parseUA(ua: string | null | undefined) {
|
|
62
|
+
const s = ua || ""
|
|
63
|
+
const browser = /edg\//i.test(s)
|
|
64
|
+
? "Edge"
|
|
65
|
+
: /chrome|crios/i.test(s)
|
|
66
|
+
? "Chrome"
|
|
67
|
+
: /firefox|fxios/i.test(s)
|
|
68
|
+
? "Firefox"
|
|
69
|
+
: /safari/i.test(s)
|
|
70
|
+
? "Safari"
|
|
71
|
+
: /curl/i.test(s)
|
|
72
|
+
? "curl"
|
|
73
|
+
: "Trình duyệt khác"
|
|
74
|
+
const os = /windows/i.test(s)
|
|
75
|
+
? "Windows"
|
|
76
|
+
: /mac os x|macintosh/i.test(s)
|
|
77
|
+
? "macOS"
|
|
78
|
+
: /android/i.test(s)
|
|
79
|
+
? "Android"
|
|
80
|
+
: /iphone|ipad|ios/i.test(s)
|
|
81
|
+
? "iOS"
|
|
82
|
+
: /linux/i.test(s)
|
|
83
|
+
? "Linux"
|
|
84
|
+
: "Không rõ"
|
|
85
|
+
const kind: "mobile" | "tablet" | "desktop" = /ipad|tablet/i.test(s)
|
|
86
|
+
? "tablet"
|
|
87
|
+
: /mobile|iphone|android/i.test(s)
|
|
88
|
+
? "mobile"
|
|
89
|
+
: "desktop"
|
|
90
|
+
return { browser, os, kind }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const KIND_ICON = {
|
|
94
|
+
mobile: Smartphone,
|
|
95
|
+
tablet: Tablet,
|
|
96
|
+
desktop: Monitor,
|
|
97
|
+
} as const
|
|
98
|
+
|
|
99
|
+
function formatTime(d: string) {
|
|
100
|
+
return new Date(d).toLocaleString("vi-VN", {
|
|
101
|
+
hour: "2-digit",
|
|
102
|
+
minute: "2-digit",
|
|
103
|
+
day: "2-digit",
|
|
104
|
+
month: "2-digit",
|
|
105
|
+
year: "numeric",
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function shortIp(ip: string | null | undefined) {
|
|
110
|
+
if (!ip) return "—"
|
|
111
|
+
if (ip === "::1" || /^0000:/.test(ip)) return "localhost"
|
|
112
|
+
return ip.replace(/^::ffff:/, "")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const PAGE_SIZES = [10, 20, 50]
|
|
116
|
+
|
|
117
|
+
export interface SessionsPageProps {
|
|
118
|
+
/** Gốc API app cung cấp (GET list · DELETE /{id} · POST /revoke-user). */
|
|
119
|
+
apiUrl?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function SessionsPage({
|
|
123
|
+
apiUrl = "/api/security/sessions",
|
|
124
|
+
}: SessionsPageProps = {}) {
|
|
125
|
+
const [items, setItems] = useState<SessionRow[]>([])
|
|
126
|
+
const [total, setTotal] = useState(0)
|
|
127
|
+
const [search, setSearch] = useState("")
|
|
128
|
+
const [page, setPage] = useState(1)
|
|
129
|
+
const [pageSize, setPageSize] = useState(20)
|
|
130
|
+
const [loading, setLoading] = useState(true)
|
|
131
|
+
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
132
|
+
const [busyId, setBusyId] = useState<string | null>(null)
|
|
133
|
+
const [confirmRow, setConfirmRow] = useState<SessionRow | null>(null)
|
|
134
|
+
const [confirmUser, setConfirmUser] = useState<SessionRow | null>(null)
|
|
135
|
+
const [revokingUser, setRevokingUser] = useState(false)
|
|
136
|
+
|
|
137
|
+
// Debounce tìm kiếm 350ms — chuẩn các toolbar khác
|
|
138
|
+
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
139
|
+
const [debouncedSearch, setDebouncedSearch] = useState("")
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (searchTimer.current) clearTimeout(searchTimer.current)
|
|
142
|
+
searchTimer.current = setTimeout(() => {
|
|
143
|
+
setDebouncedSearch(search)
|
|
144
|
+
setPage(1)
|
|
145
|
+
}, 350)
|
|
146
|
+
return () => {
|
|
147
|
+
if (searchTimer.current) clearTimeout(searchTimer.current)
|
|
148
|
+
}
|
|
149
|
+
}, [search])
|
|
150
|
+
|
|
151
|
+
const fetchData = useCallback(async () => {
|
|
152
|
+
try {
|
|
153
|
+
const params = new URLSearchParams({
|
|
154
|
+
page: String(page),
|
|
155
|
+
pageSize: String(pageSize),
|
|
156
|
+
})
|
|
157
|
+
if (debouncedSearch) params.set("search", debouncedSearch)
|
|
158
|
+
const res = await fetch(`${apiUrl}?${params}`, {
|
|
159
|
+
cache: "no-store",
|
|
160
|
+
})
|
|
161
|
+
if (!res.ok) throw new Error("Không tải được danh sách phiên")
|
|
162
|
+
const data = await res.json()
|
|
163
|
+
setItems(data.items ?? [])
|
|
164
|
+
setTotal(data.total ?? 0)
|
|
165
|
+
} catch (e) {
|
|
166
|
+
toast.error(e instanceof Error ? e.message : "Lỗi tải dữ liệu")
|
|
167
|
+
} finally {
|
|
168
|
+
setLoading(false)
|
|
169
|
+
setIsRefreshing(false)
|
|
170
|
+
}
|
|
171
|
+
}, [apiUrl, page, pageSize, debouncedSearch])
|
|
172
|
+
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
fetchData()
|
|
175
|
+
}, [fetchData])
|
|
176
|
+
|
|
177
|
+
const handleRefresh = () => {
|
|
178
|
+
setIsRefreshing(true)
|
|
179
|
+
fetchData()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const revokeOne = async (row: SessionRow) => {
|
|
183
|
+
setBusyId(row.id)
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(`${apiUrl}/${row.id}`, {
|
|
186
|
+
method: "DELETE",
|
|
187
|
+
})
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const data = await res.json().catch(() => null)
|
|
190
|
+
throw new Error(data?.error || "Không thu hồi được phiên")
|
|
191
|
+
}
|
|
192
|
+
toast.success("Đã thu hồi phiên đăng nhập")
|
|
193
|
+
setConfirmRow(null)
|
|
194
|
+
await fetchData()
|
|
195
|
+
} catch (e) {
|
|
196
|
+
toast.error(e instanceof Error ? e.message : "Không thu hồi được phiên")
|
|
197
|
+
} finally {
|
|
198
|
+
setBusyId(null)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const revokeAllOfUser = async (row: SessionRow) => {
|
|
203
|
+
setRevokingUser(true)
|
|
204
|
+
try {
|
|
205
|
+
const res = await fetch(`${apiUrl}/revoke-user`, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: { "content-type": "application/json" },
|
|
208
|
+
body: JSON.stringify({ userId: row.userId }),
|
|
209
|
+
})
|
|
210
|
+
const data = await res.json().catch(() => null)
|
|
211
|
+
if (!res.ok) throw new Error(data?.error || "Không thu hồi được phiên")
|
|
212
|
+
toast.success(
|
|
213
|
+
`Đã đăng xuất ${data?.revoked ?? 0} thiết bị của ${row.userName || row.userEmail}`
|
|
214
|
+
)
|
|
215
|
+
setConfirmUser(null)
|
|
216
|
+
await fetchData()
|
|
217
|
+
} catch (e) {
|
|
218
|
+
toast.error(e instanceof Error ? e.message : "Không thu hồi được phiên")
|
|
219
|
+
} finally {
|
|
220
|
+
setRevokingUser(false)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const totalPages = Math.max(Math.ceil(total / pageSize), 1)
|
|
225
|
+
const fromRow = total === 0 ? 0 : (page - 1) * pageSize + 1
|
|
226
|
+
const toRow = Math.min(page * pageSize, total)
|
|
227
|
+
const isSearching = debouncedSearch.length > 0
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex flex-col">
|
|
231
|
+
{/* ===== Bar navy 1 dòng ===== */}
|
|
232
|
+
<div className="-mx-4 -mt-4 flex flex-wrap items-center gap-1.5 bg-sidebar px-3 py-2 text-sidebar-foreground shadow-md md:-mx-8 lg:mx-3 lg:mt-1 lg:rounded-xl lg:px-4">
|
|
233
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
|
|
234
|
+
<MonitorSmartphone className="h-4 w-4 text-primary-foreground/80" />
|
|
235
|
+
</span>
|
|
236
|
+
<h1 className="min-w-0 flex-1 truncate px-1 text-base font-bold text-primary-foreground sm:text-lg">
|
|
237
|
+
Quản lý phiên đăng nhập
|
|
238
|
+
</h1>
|
|
239
|
+
<span className="hidden shrink-0 text-xs tabular-nums text-primary-foreground/70 md:inline">
|
|
240
|
+
{total} phiên đang hoạt động toàn hệ thống
|
|
241
|
+
</span>
|
|
242
|
+
<Button
|
|
243
|
+
variant="ghost"
|
|
244
|
+
size="icon"
|
|
245
|
+
onClick={handleRefresh}
|
|
246
|
+
disabled={isRefreshing}
|
|
247
|
+
title="Làm mới"
|
|
248
|
+
className="h-8 w-8 shrink-0 text-primary-foreground/80 hover:bg-white/10 hover:text-primary-foreground"
|
|
249
|
+
>
|
|
250
|
+
<RefreshCw
|
|
251
|
+
className={cn("h-4 w-4", isRefreshing && "animate-spin")}
|
|
252
|
+
/>
|
|
253
|
+
</Button>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{/* ===== Khối trắng: toolbar sticky + danh sách + phân trang ===== */}
|
|
257
|
+
<div className="mt-3 overflow-hidden rounded-xl border border-border bg-card shadow-sm lg:mx-3">
|
|
258
|
+
{/* Toolbar sticky — tìm kiếm chuẩn */}
|
|
259
|
+
<div className="sticky top-0 z-20 flex flex-wrap items-center gap-2 border-b border-border bg-card/95 px-3 py-2 backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:px-4">
|
|
260
|
+
<div className="relative min-w-[160px] flex-1 sm:max-w-80">
|
|
261
|
+
<Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
262
|
+
<input
|
|
263
|
+
placeholder="Tìm theo tên, email, IP, thiết bị…"
|
|
264
|
+
className="h-8 w-full rounded-md border border-border bg-card pl-8 pr-8 text-sm outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
|
265
|
+
value={search}
|
|
266
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
267
|
+
/>
|
|
268
|
+
{search && (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
onClick={() => setSearch("")}
|
|
272
|
+
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
273
|
+
title="Xóa tìm kiếm"
|
|
274
|
+
>
|
|
275
|
+
<X className="h-3.5 w-3.5" />
|
|
276
|
+
</button>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
280
|
+
<span className="font-semibold tabular-nums text-foreground">
|
|
281
|
+
{total}
|
|
282
|
+
</span>{" "}
|
|
283
|
+
{isSearching ? "khớp" : "phiên"}
|
|
284
|
+
</p>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* Danh sách */}
|
|
288
|
+
{loading ? (
|
|
289
|
+
<div className="space-y-2 p-4">
|
|
290
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
291
|
+
<Skeleton key={i} className="h-16 w-full" />
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
) : items.length === 0 ? (
|
|
295
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
296
|
+
<Globe className="mb-3 h-6 w-6 text-muted-foreground" />
|
|
297
|
+
<p className="text-sm text-muted-foreground">
|
|
298
|
+
{isSearching
|
|
299
|
+
? "Không có phiên nào khớp từ khóa."
|
|
300
|
+
: "Không có phiên nào đang hoạt động."}
|
|
301
|
+
</p>
|
|
302
|
+
</div>
|
|
303
|
+
) : (
|
|
304
|
+
<ul className="divide-y divide-border">
|
|
305
|
+
{items.map((s) => {
|
|
306
|
+
const ua = parseUA(s.userAgent)
|
|
307
|
+
const KindIcon = KIND_ICON[ua.kind]
|
|
308
|
+
return (
|
|
309
|
+
<li
|
|
310
|
+
key={s.id}
|
|
311
|
+
className="flex items-center gap-3 bg-card px-4 py-3"
|
|
312
|
+
>
|
|
313
|
+
<span
|
|
314
|
+
className={cn(
|
|
315
|
+
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border",
|
|
316
|
+
s.isCurrent
|
|
317
|
+
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-400"
|
|
318
|
+
: "border-border bg-card text-muted-foreground"
|
|
319
|
+
)}
|
|
320
|
+
>
|
|
321
|
+
<KindIcon className="h-4 w-4" />
|
|
322
|
+
</span>
|
|
323
|
+
|
|
324
|
+
<div className="min-w-0 flex-1">
|
|
325
|
+
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
326
|
+
<span className="text-sm font-semibold text-foreground">
|
|
327
|
+
{s.userName || s.userEmail || s.userId}
|
|
328
|
+
</span>
|
|
329
|
+
<span className="text-sm text-muted-foreground">
|
|
330
|
+
{ua.browser} · {ua.os}
|
|
331
|
+
</span>
|
|
332
|
+
{s.isCurrent && (
|
|
333
|
+
<Badge
|
|
334
|
+
variant="outline"
|
|
335
|
+
className="h-5 border-emerald-200 bg-emerald-50 px-1.5 text-[10px] font-semibold text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400"
|
|
336
|
+
>
|
|
337
|
+
Thiết bị này
|
|
338
|
+
</Badge>
|
|
339
|
+
)}
|
|
340
|
+
{s.isMine && !s.isCurrent && (
|
|
341
|
+
<Badge
|
|
342
|
+
variant="outline"
|
|
343
|
+
className="h-5 border-border px-1.5 text-[10px] font-medium text-muted-foreground"
|
|
344
|
+
>
|
|
345
|
+
Của tôi
|
|
346
|
+
</Badge>
|
|
347
|
+
)}
|
|
348
|
+
{s.impersonatedBy && (
|
|
349
|
+
<Badge
|
|
350
|
+
variant="outline"
|
|
351
|
+
className="h-5 border-amber-200 bg-amber-50 px-1.5 text-[10px] font-semibold text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300"
|
|
352
|
+
title="Phiên do quản trị viên đăng nhập thay"
|
|
353
|
+
>
|
|
354
|
+
Mạo danh
|
|
355
|
+
</Badge>
|
|
356
|
+
)}
|
|
357
|
+
</div>
|
|
358
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
359
|
+
{s.userEmail ? `${s.userEmail} · ` : ""}IP{" "}
|
|
360
|
+
<span className="font-mono">{shortIp(s.ipAddress)}</span>
|
|
361
|
+
{" · "}đăng nhập {formatTime(s.createdAt)}
|
|
362
|
+
{" · "}hết hạn {formatTime(s.expiresAt)}
|
|
363
|
+
</p>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
{/* Hành động — LUÔN hiển thị (phiên hiện tại: disabled kèm
|
|
367
|
+
lý do, không ẩn để khỏi tưởng trang chỉ-đọc) */}
|
|
368
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
369
|
+
<button
|
|
370
|
+
type="button"
|
|
371
|
+
onClick={() => setConfirmRow(s)}
|
|
372
|
+
disabled={s.isCurrent || busyId === s.id}
|
|
373
|
+
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1.5 text-xs font-medium text-destructive shadow-sm transition-colors hover:border-destructive/40 hover:bg-destructive/10 disabled:cursor-not-allowed disabled:opacity-40"
|
|
374
|
+
title={
|
|
375
|
+
s.isCurrent
|
|
376
|
+
? "Phiên bạn đang dùng — không thể tự thu hồi (dùng Đăng xuất)"
|
|
377
|
+
: "Đăng xuất thiết bị này ngay lập tức"
|
|
378
|
+
}
|
|
379
|
+
>
|
|
380
|
+
<Trash2
|
|
381
|
+
className={cn(
|
|
382
|
+
"h-3.5 w-3.5",
|
|
383
|
+
busyId === s.id && "animate-pulse"
|
|
384
|
+
)}
|
|
385
|
+
/>
|
|
386
|
+
<span className="hidden sm:inline">Thu hồi</span>
|
|
387
|
+
</button>
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
onClick={() => setConfirmUser(s)}
|
|
391
|
+
className="inline-flex items-center rounded-md border border-border bg-card p-1.5 text-amber-600 shadow-sm transition-colors hover:border-amber-300 hover:bg-amber-50 dark:hover:border-amber-500/40 dark:hover:bg-amber-500/10"
|
|
392
|
+
title={`Đăng xuất MỌI thiết bị của ${s.userName || s.userEmail || "người dùng này"}`}
|
|
393
|
+
>
|
|
394
|
+
<UserX className="h-3.5 w-3.5" />
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
397
|
+
</li>
|
|
398
|
+
)
|
|
399
|
+
})}
|
|
400
|
+
</ul>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{/* Footer phân trang chuẩn */}
|
|
404
|
+
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2 sm:px-4">
|
|
405
|
+
<p className="text-xs text-muted-foreground">
|
|
406
|
+
Hiển thị {fromRow} - {toRow} trong tổng số {total} phiên
|
|
407
|
+
</p>
|
|
408
|
+
<div className="ml-auto flex items-center gap-2">
|
|
409
|
+
<span className="text-xs text-muted-foreground">Số dòng</span>
|
|
410
|
+
<Select
|
|
411
|
+
value={String(pageSize)}
|
|
412
|
+
onValueChange={(v) => {
|
|
413
|
+
setPageSize(Number(v))
|
|
414
|
+
setPage(1)
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
<SelectTrigger className="h-7 w-[70px] text-xs">
|
|
418
|
+
<SelectValue />
|
|
419
|
+
</SelectTrigger>
|
|
420
|
+
<SelectContent>
|
|
421
|
+
{PAGE_SIZES.map((n) => (
|
|
422
|
+
<SelectItem key={n} value={String(n)}>
|
|
423
|
+
{n}
|
|
424
|
+
</SelectItem>
|
|
425
|
+
))}
|
|
426
|
+
</SelectContent>
|
|
427
|
+
</Select>
|
|
428
|
+
<div className="flex items-center gap-1">
|
|
429
|
+
<Button
|
|
430
|
+
variant="outline"
|
|
431
|
+
size="icon"
|
|
432
|
+
className="h-7 w-7"
|
|
433
|
+
disabled={page <= 1}
|
|
434
|
+
onClick={() => setPage(1)}
|
|
435
|
+
title="Trang đầu"
|
|
436
|
+
>
|
|
437
|
+
<ChevronsLeft className="h-3.5 w-3.5" />
|
|
438
|
+
</Button>
|
|
439
|
+
<Button
|
|
440
|
+
variant="outline"
|
|
441
|
+
size="icon"
|
|
442
|
+
className="h-7 w-7"
|
|
443
|
+
disabled={page <= 1}
|
|
444
|
+
onClick={() => setPage((p) => p - 1)}
|
|
445
|
+
title="Trang trước"
|
|
446
|
+
>
|
|
447
|
+
<ChevronLeft className="h-3.5 w-3.5" />
|
|
448
|
+
</Button>
|
|
449
|
+
<span className="px-1 text-xs tabular-nums text-muted-foreground">
|
|
450
|
+
Trang{" "}
|
|
451
|
+
<span className="font-semibold text-foreground">{page}</span> /{" "}
|
|
452
|
+
{totalPages}
|
|
453
|
+
</span>
|
|
454
|
+
<Button
|
|
455
|
+
variant="outline"
|
|
456
|
+
size="icon"
|
|
457
|
+
className="h-7 w-7"
|
|
458
|
+
disabled={page >= totalPages}
|
|
459
|
+
onClick={() => setPage((p) => p + 1)}
|
|
460
|
+
title="Trang sau"
|
|
461
|
+
>
|
|
462
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
463
|
+
</Button>
|
|
464
|
+
<Button
|
|
465
|
+
variant="outline"
|
|
466
|
+
size="icon"
|
|
467
|
+
className="h-7 w-7"
|
|
468
|
+
disabled={page >= totalPages}
|
|
469
|
+
onClick={() => setPage(totalPages)}
|
|
470
|
+
title="Trang cuối"
|
|
471
|
+
>
|
|
472
|
+
<ChevronsRight className="h-3.5 w-3.5" />
|
|
473
|
+
</Button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<ConfirmDialog
|
|
480
|
+
open={!!confirmRow}
|
|
481
|
+
onOpenChange={(open) => !open && setConfirmRow(null)}
|
|
482
|
+
title="Thu hồi phiên đăng nhập"
|
|
483
|
+
description={
|
|
484
|
+
confirmRow
|
|
485
|
+
? `Thu hồi phiên ${parseUA(confirmRow.userAgent).browser} · ${parseUA(confirmRow.userAgent).os} của ${
|
|
486
|
+
confirmRow.userName || confirmRow.userEmail || confirmRow.userId
|
|
487
|
+
}? Thiết bị đó sẽ bị đăng xuất ngay lập tức.`
|
|
488
|
+
: ""
|
|
489
|
+
}
|
|
490
|
+
onConfirm={() => confirmRow && revokeOne(confirmRow)}
|
|
491
|
+
loading={!!busyId}
|
|
492
|
+
/>
|
|
493
|
+
|
|
494
|
+
<ConfirmDialog
|
|
495
|
+
open={!!confirmUser}
|
|
496
|
+
onOpenChange={(open) => !open && setConfirmUser(null)}
|
|
497
|
+
title="Đăng xuất mọi thiết bị của người dùng"
|
|
498
|
+
description={
|
|
499
|
+
confirmUser
|
|
500
|
+
? `Thu hồi TẤT CẢ phiên đăng nhập của ${
|
|
501
|
+
confirmUser.userName ||
|
|
502
|
+
confirmUser.userEmail ||
|
|
503
|
+
confirmUser.userId
|
|
504
|
+
} trên mọi thiết bị. Người này sẽ phải đăng nhập lại. ${
|
|
505
|
+
confirmUser.isMine
|
|
506
|
+
? "(Bạn tự thu hồi — phiên hiện tại của bạn sẽ được giữ lại.)"
|
|
507
|
+
: ""
|
|
508
|
+
}`
|
|
509
|
+
: ""
|
|
510
|
+
}
|
|
511
|
+
onConfirm={() => confirmUser && revokeAllOfUser(confirmUser)}
|
|
512
|
+
loading={revokingUser}
|
|
513
|
+
/>
|
|
514
|
+
</div>
|
|
515
|
+
)
|
|
516
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -302,7 +302,28 @@ export interface EntityConfig {
|
|
|
302
302
|
*/
|
|
303
303
|
permissionResource?: string;
|
|
304
304
|
apiEndpoint: string;
|
|
305
|
+
/**
|
|
306
|
+
* Endpoint import/export. Không khai thì suy từ apiEndpoint theo CÙNG MỘT
|
|
307
|
+
* quy tắc: `${base}/import|/export` (giữ nguyên query params nếu có) —
|
|
308
|
+
* xem getEntityEndpoints ở crud/lib/entity-endpoints.
|
|
309
|
+
*/
|
|
310
|
+
endpoints?: {
|
|
311
|
+
import?: string;
|
|
312
|
+
export?: string;
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Tên model Prisma (camelCase, vd "jobTitle"). Không khai thì engine suy
|
|
316
|
+
* theo convention từ entity key (bỏ "s" cuối + camelCase kebab) — khai ở
|
|
317
|
+
* đây thay cho MODEL_MAP riêng phía app.
|
|
318
|
+
*/
|
|
319
|
+
modelName?: string;
|
|
305
320
|
idField: string;
|
|
321
|
+
/**
|
|
322
|
+
* Kiểu khóa chính. "string" (default): create thiếu id sẽ tự sinh UUID.
|
|
323
|
+
* "int": KHÔNG BAO GIỜ tự sinh (DB autoincrement), id trên URL được ép
|
|
324
|
+
* Number — sai định dạng trả 400.
|
|
325
|
+
*/
|
|
326
|
+
idKind?: "string" | "int";
|
|
306
327
|
displayField: string;
|
|
307
328
|
fields: FieldConfig[];
|
|
308
329
|
filters?: FilterConfig[];
|
|
@@ -345,19 +366,32 @@ export interface FormModeConfig {
|
|
|
345
366
|
sheetSide?: "top" | "right" | "bottom" | "left";
|
|
346
367
|
}
|
|
347
368
|
|
|
369
|
+
/** Các action built-in có icon + hành vi mặc định trong CrudPage/CrudRowActions. */
|
|
370
|
+
export type BuiltinRowActionKind =
|
|
371
|
+
| "copy"
|
|
372
|
+
| "duplicate"
|
|
373
|
+
| "view"
|
|
374
|
+
| "archive"
|
|
375
|
+
| "custom"
|
|
376
|
+
| "approve"
|
|
377
|
+
| "reject"
|
|
378
|
+
| "set-default"
|
|
379
|
+
| "view-history";
|
|
380
|
+
|
|
348
381
|
export interface RowAction {
|
|
349
382
|
label: string;
|
|
350
383
|
icon?: string;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Built-in giữ hành vi/icon cũ; chuỗi tự do = action tự khai — đi kèm
|
|
386
|
+
* `urlTemplate` (dispatcher HTTP của CrudPage) hoặc handler app-side.
|
|
387
|
+
* `(string & {})` để vẫn gợi ý được literal built-in.
|
|
388
|
+
*/
|
|
389
|
+
action: BuiltinRowActionKind | (string & {});
|
|
390
|
+
/**
|
|
391
|
+
* handler/visibleWhen/transformData KHÔNG sống sót qua serializeConfig
|
|
392
|
+
* (RSC → client) — chỉ dùng khi config đi thẳng vào CrudPage phía client.
|
|
393
|
+
* Cần action serialize an toàn thì dùng bộ urlTemplate/method bên dưới.
|
|
394
|
+
*/
|
|
361
395
|
handler?: (
|
|
362
396
|
rowId: string,
|
|
363
397
|
rowData: Record<string, unknown>,
|
|
@@ -372,6 +406,24 @@ export interface RowAction {
|
|
|
372
406
|
| "link";
|
|
373
407
|
excludeFields?: string[];
|
|
374
408
|
transformData?: (rowData: Record<string, unknown>) => Record<string, unknown>;
|
|
409
|
+
|
|
410
|
+
// ── Biến thể HTTP thuần data (serialize qua RSC được) ──
|
|
411
|
+
/** URL gọi khi bấm — "{id}" được thay bằng id dòng, vd "/api/employees/{id}/reset-password". */
|
|
412
|
+
urlTemplate?: string;
|
|
413
|
+
/** Method cho urlTemplate. Default: POST. */
|
|
414
|
+
method?: "POST" | "PATCH" | "DELETE";
|
|
415
|
+
/** Có khai thì window.confirm trước khi gọi. */
|
|
416
|
+
confirmMessage?: string;
|
|
417
|
+
/** Toast khi thành công. Không khai thì dùng message "updated" chung. */
|
|
418
|
+
successMessage?: string;
|
|
419
|
+
/**
|
|
420
|
+
* Gate hiển thị + thực thi theo action code trong quyền. Nhận cả 8 action
|
|
421
|
+
* CRUD chuẩn ("update", "approve"…) lẫn CUSTOM ACTION khai trong registry
|
|
422
|
+
* ("reset-password", "resync"…) — EntityCrudPage tự gom các permissionAction
|
|
423
|
+
* custom trong rowActions và hỏi checkPermission cho từng cái. Fail-closed:
|
|
424
|
+
* action không có trong map quyền thì nút bị chặn.
|
|
425
|
+
*/
|
|
426
|
+
permissionAction?: string;
|
|
375
427
|
}
|
|
376
428
|
|
|
377
429
|
// ============================================================================
|
|
@@ -402,6 +454,13 @@ export interface FieldConfig {
|
|
|
402
454
|
name: string;
|
|
403
455
|
label: string;
|
|
404
456
|
type: FieldType;
|
|
457
|
+
/**
|
|
458
|
+
* Kiểu GIÁ TRỊ thô của field khi kiểu cột không suy được từ `type` — cột FK
|
|
459
|
+
* Int autoincrement hiện qua select/multiselect/relation: form và URL chở
|
|
460
|
+
* chuỗi "3", khai `valueType: "int"` để coercion tập trung (coerceFieldValue)
|
|
461
|
+
* ép Number trước khi vào Prisma. Default: "string" (giữ nguyên).
|
|
462
|
+
*/
|
|
463
|
+
valueType?: "string" | "int";
|
|
405
464
|
required?: boolean;
|
|
406
465
|
defaultValue?: unknown;
|
|
407
466
|
placeholder?: string;
|
|
@@ -658,6 +717,12 @@ export interface CrudPermissions {
|
|
|
658
717
|
import?: boolean;
|
|
659
718
|
approve?: boolean;
|
|
660
719
|
reject?: boolean;
|
|
720
|
+
/**
|
|
721
|
+
* Custom action từ registry (vd "reset-password") — getCrudPermissions nhận
|
|
722
|
+
* danh sách extraActions và điền vào đây; RowAction.permissionAction tra
|
|
723
|
+
* thẳng map này, thiếu key = false (fail-closed).
|
|
724
|
+
*/
|
|
725
|
+
[action: string]: boolean | undefined;
|
|
661
726
|
}
|
|
662
727
|
|
|
663
728
|
export interface CrudFeatures {
|