@goplusvn/core 0.1.69 → 0.1.71

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +1 -1
  4. package/src/auth/proxy-gate.ts +21 -2
  5. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  6. package/src/cron/cron-schedule.ts +128 -0
  7. package/src/cron/simple-cron-job.ts +83 -69
  8. package/src/crud/components/crud-table.tsx +13 -1
  9. package/src/crud/lib/translate-config.ts +16 -2
  10. package/src/guardrails/index.ts +1 -0
  11. package/src/guardrails/rules/auth.ts +54 -1
  12. package/src/guardrails/types.ts +17 -8
  13. package/src/print/print-styles.tsx +4 -1
  14. package/src/providers/brand-theme.ts +20 -0
  15. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  16. package/src/security/index.ts +2 -0
  17. package/src/security/pages/sessions-page.tsx +516 -0
  18. package/src/ui/auth/sign-in-form.tsx +64 -9
  19. package/src/ui/errors/error-view.tsx +164 -0
  20. package/src/ui/errors/index.ts +2 -0
  21. package/src/ui/errors/not-found-view.tsx +44 -0
  22. package/src/ui/index.tsx +1 -0
  23. package/src/ui/layout/logo.tsx +54 -19
  24. package/src/user/pages/users-client-page.tsx +12 -7
  25. package/templates/starter-app/.env.example +4 -0
  26. package/templates/starter-app/README.md +6 -2
  27. package/templates/starter-app/husky/pre-commit +10 -0
  28. package/templates/starter-app/package.json +27 -2
  29. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  30. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  31. package/templates/starter-app/prisma/seed.ts +7 -5
  32. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  33. package/templates/starter-app/src/__tests__/architecture.test.ts +8 -0
  34. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  35. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +69 -0
  36. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
  37. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
  38. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
  39. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  40. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  41. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
  42. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  43. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +60 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -0
  49. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  50. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  51. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  52. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  53. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  54. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  55. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  56. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  57. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  58. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  59. package/templates/starter-app/src/app/api/system-categories/route.ts +154 -0
  60. package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
  61. package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
  62. package/templates/starter-app/src/app/api/users/route.ts +139 -0
  63. package/templates/starter-app/src/app/global-error.tsx +135 -0
  64. package/templates/starter-app/src/app/icon.svg +6 -0
  65. package/templates/starter-app/src/app/manifest.ts +26 -0
  66. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  67. package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
  68. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
  69. package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
  70. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  71. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  72. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  73. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  74. package/templates/starter-app/src/configs/tenant.ts +2 -1
  75. package/templates/starter-app/src/data/dictionary.ts +76 -4
  76. package/templates/starter-app/src/data/navigations.ts +49 -0
  77. package/templates/starter-app/src/instrumentation.ts +5 -16
  78. package/templates/starter-app/src/lib/action-guard.ts +26 -0
  79. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  80. package/templates/starter-app/src/lib/crud/index.ts +2 -0
  81. package/templates/starter-app/src/lib/prisma.ts +3 -2
  82. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  83. package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
  84. package/templates/starter-app/src/proxy.ts +8 -3
  85. package/templates/starter-app/src/server/services/company-service.ts +47 -0
  86. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  87. package/templates/starter-app/tsconfig.json +2 -0
@@ -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
+ }
@@ -8,12 +8,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
8
8
  import { useAuthBridge } from "./auth-bridge";
9
9
  import { useForm } from "react-hook-form";
10
10
  import { Eye, EyeOff } from "lucide-react";
11
- import type { z } from "zod"; // Add this import
12
11
 
13
12
  import type { LocaleType } from "../../types";
14
13
  import { toast } from "sonner";
14
+ import { z } from "zod";
15
15
  import { ensureLocalizedPathname, ensureRedirectPathname } from "../../utils";
16
- import { SignInSchema } from "../../schemas";
17
16
  import { useTabContentCache } from "../layout/tab-content-cache";
18
17
  import { useTabNavigation } from "../layout/tab-navigation-provider";
19
18
 
@@ -34,9 +33,64 @@ import {
34
33
  } from "../index";
35
34
  import { OAuthLinks } from "./oauth-links";
36
35
 
37
- type SignInFormType = z.infer<typeof SignInSchema>;
36
+ /**
37
+ * Ô định danh nhận gì:
38
+ * - "email" (mặc định, giữ hành vi cũ): validate + input type email.
39
+ * - "username": tên đăng nhập/mã nhân viên — text tự do.
40
+ * - "both": một ô cho cả hai; bridge của app tự phân loại (thường theo "@").
41
+ *
42
+ * Mật khẩu ở form ĐĂNG NHẬP chỉ đòi khác rỗng — luật độ phức tạp là chuyện lúc
43
+ * ĐẶT mật khẩu; siết ở đây sẽ chặn nhầm mật khẩu cũ hợp lệ import từ hệ khác.
44
+ */
45
+ export interface SignInFormProps {
46
+ identifier?: "email" | "username" | "both";
47
+ /** Ghi đè nhãn ô định danh (vd "Email hoặc mã nhân viên"). */
48
+ identifierLabel?: string;
49
+ identifierPlaceholder?: string;
50
+ }
51
+
52
+ const IDENTIFIER_PRESETS = {
53
+ email: {
54
+ label: "Email",
55
+ placeholder: "name@example.com",
56
+ inputType: "email" as const,
57
+ schema: z.string().trim().toLowerCase().email({ message: "Email không hợp lệ" }),
58
+ },
59
+ username: {
60
+ label: "Tên đăng nhập",
61
+ placeholder: "ten-dang-nhap",
62
+ inputType: "text" as const,
63
+ schema: z.string().trim().min(1, { message: "Nhập tên đăng nhập" }),
64
+ },
65
+ both: {
66
+ label: "Email hoặc tên đăng nhập",
67
+ placeholder: "name@example.com",
68
+ inputType: "text" as const,
69
+ schema: z.string().trim().min(1, { message: "Nhập email hoặc tên đăng nhập" }),
70
+ },
71
+ };
38
72
 
39
- export function SignInForm() {
73
+ type SignInFormType = {
74
+ email: string;
75
+ password: string;
76
+ rememberMe?: boolean;
77
+ };
78
+
79
+ export function SignInForm({
80
+ identifier = "email",
81
+ identifierLabel,
82
+ identifierPlaceholder,
83
+ }: SignInFormProps = {}) {
84
+ const preset = IDENTIFIER_PRESETS[identifier];
85
+ const schema = React.useMemo(
86
+ () =>
87
+ z.object({
88
+ email: preset.schema,
89
+ password: z.string().min(1, { message: "Nhập mật khẩu" }),
90
+ rememberMe: z.boolean().optional(),
91
+ }),
92
+ [preset],
93
+ );
40
94
  const { signInWithCredentials } = useAuthBridge();
41
95
  const params = useParams();
42
96
  const searchParams = useSearchParams();
@@ -50,7 +104,7 @@ export function SignInForm() {
50
104
  "/";
51
105
 
52
106
  const form = useForm<SignInFormType>({
53
- resolver: zodResolver(SignInSchema),
107
+ resolver: zodResolver(schema),
54
108
  defaultValues: {
55
109
  email: "",
56
110
  password: "",
@@ -125,11 +179,12 @@ export function SignInForm() {
125
179
  name="email"
126
180
  render={({ field }) => (
127
181
  <FormItem>
128
- <FormLabel>Email</FormLabel>
182
+ <FormLabel>{identifierLabel ?? preset.label}</FormLabel>
129
183
  <FormControl>
130
184
  <Input
131
- type="email"
132
- placeholder="name@example.com"
185
+ type={preset.inputType}
186
+ autoComplete={identifier === "email" ? "email" : "username"}
187
+ placeholder={identifierPlaceholder ?? preset.placeholder}
133
188
  {...field}
134
189
  />
135
190
  </FormControl>
@@ -210,7 +265,7 @@ export function SignInForm() {
210
265
  </div>
211
266
 
212
267
  <ButtonLoading isLoading={isSubmitting} disabled={isDisabled}>
213
- Đăng nhập với Email
268
+ Đăng nhập
214
269
  </ButtonLoading>
215
270
  <div className="-mt-4 text-center text-sm">
216
271
  Chưa có tài khoản?{" "}