@goplusvn/core 0.1.70 → 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.
- package/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +1 -1
- package/src/auth/proxy-gate.ts +21 -2
- 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-table.tsx +13 -1
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- 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/ui/auth/sign-in-form.tsx +64 -9
- 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/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/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/src/__tests__/architecture.test.ts +8 -0
- 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 +69 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -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 +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -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]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- 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 +154 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
- package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
- package/templates/starter-app/src/app/api/users/route.ts +139 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- 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/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
- 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 +76 -4
- package/templates/starter-app/src/data/navigations.ts +49 -0
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +26 -0
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/crud/index.ts +2 -0
- package/templates/starter-app/src/lib/prisma.ts +3 -2
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +47 -0
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/tsconfig.json +2 -0
|
@@ -42,6 +42,26 @@ function publicPrefixes(ctx: GuardrailContext): string[] {
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* MỌI tiền tố trong danh sách công khai của proxy.ts — kể cả ngoài /api
|
|
47
|
+
* (/pub, /iclock, webhook…). Chỉ đọc bên trong mảng `publicPrefixes:`/
|
|
48
|
+
* `publicApiPrefixes:` chứ không vơ mọi chuỗi (signInPath/homePath không phải
|
|
49
|
+
* tuyên bố công khai).
|
|
50
|
+
*/
|
|
51
|
+
function declaredPublicPrefixes(ctx: GuardrailContext): string[] {
|
|
52
|
+
if (!ctx.existsInSrc("proxy.ts")) return [];
|
|
53
|
+
const code = ctx.readCodeRel("proxy.ts");
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
for (const m of code.matchAll(
|
|
56
|
+
/public(?:Api)?Prefixes\s*:\s*\[([^\]]*)\]/g,
|
|
57
|
+
)) {
|
|
58
|
+
for (const s of m[1].matchAll(/["'](\/[^"']*)["']/g)) {
|
|
59
|
+
out.push(s[1].replace(/\/$/, ""));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
function gateSources(ctx: GuardrailContext, code: string): string[] {
|
|
46
66
|
const targets = [...code.matchAll(/export \* from ["']@\/([^"']+)["']/g)].map(
|
|
47
67
|
(m) => m[1],
|
|
@@ -77,7 +97,8 @@ export const authRules: GuardrailRule[] = [
|
|
|
77
97
|
// Hai lối dựng đều hợp lệ: factory của core, hoặc bản tự dựng của app.
|
|
78
98
|
// Ràng cứng vào một lối là bắt app phải giống hệt vinhhoa về CÁCH VIẾT,
|
|
79
99
|
// trong khi thứ cần ràng là TÍNH CHẤT: /api chặn trước, công khai phải khai.
|
|
80
|
-
const viaCore =
|
|
100
|
+
const viaCore =
|
|
101
|
+
/createAuthProxy\s*\(/.test(code) && /public(?:Api)?Prefixes/.test(code);
|
|
81
102
|
const handRolled =
|
|
82
103
|
/getSessionCookie/.test(code) &&
|
|
83
104
|
/pathname\.startsWith\(["']\/api["']\)/.test(code) &&
|
|
@@ -151,6 +172,38 @@ export const authRules: GuardrailRule[] = [
|
|
|
151
172
|
},
|
|
152
173
|
},
|
|
153
174
|
|
|
175
|
+
{
|
|
176
|
+
id: "auth/route-outside-api-declared",
|
|
177
|
+
title: "route.ts NGOÀI app/api phải khai tiền tố trong danh sách công khai của proxy, hoặc tự gác",
|
|
178
|
+
why:
|
|
179
|
+
"Endpoint server ngoài /api (route thiết bị /pub, /iclock của spartronics; " +
|
|
180
|
+
"webhook đặt ngoài /api…) từng VÔ HÌNH với mọi thước: rule api-route-gated " +
|
|
181
|
+
"chỉ quét app/api/**, còn proxy mặc-định-chặn thì các route này lại cần được " +
|
|
182
|
+
"MỞ — một bề mặt công khai không ai review thấy.",
|
|
183
|
+
fix: "Thêm tiền tố vào `publicPrefixes` trong src/proxy.ts kèm ghi rõ nó tự xác thực bằng gì (allowlist serial, HMAC…), hoặc bọc cổng như route API thường.",
|
|
184
|
+
run(ctx) {
|
|
185
|
+
const routes = ctx.files
|
|
186
|
+
.map(ctx.rel)
|
|
187
|
+
.filter(
|
|
188
|
+
(rel) =>
|
|
189
|
+
rel.startsWith("app/") &&
|
|
190
|
+
rel.endsWith("/route.ts") &&
|
|
191
|
+
!rel.startsWith("app/api/"),
|
|
192
|
+
);
|
|
193
|
+
if (routes.length === 0) return null;
|
|
194
|
+
const publics = declaredPublicPrefixes(ctx);
|
|
195
|
+
const allowed = new Set(
|
|
196
|
+
ctx.options.allowlists["auth/route-outside-api-declared"] ?? [],
|
|
197
|
+
);
|
|
198
|
+
return routes.filter((rel) => {
|
|
199
|
+
if (allowed.has(rel)) return false;
|
|
200
|
+
const url = routeUrl(rel);
|
|
201
|
+
if (publics.some((p) => url === p || url.startsWith(`${p}/`))) return false;
|
|
202
|
+
return !gateSources(ctx, ctx.readCodeRel(rel)).some((src) => GATED.test(src));
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
|
|
154
207
|
forbidPattern({
|
|
155
208
|
id: "auth/server-action-bare-session",
|
|
156
209
|
title: "file 'use server' không gọi getSession trần — dùng requirePermission",
|
package/src/guardrails/types.ts
CHANGED
|
@@ -81,9 +81,9 @@ export interface GuardrailOptions {
|
|
|
81
81
|
* Registry quyền của app (`src/configs/permissions`). Thiếu thì nhóm rule
|
|
82
82
|
* `rbac/*` tự bỏ qua.
|
|
83
83
|
*/
|
|
84
|
-
permissionRegistry?: PermissionFeatureLike[];
|
|
84
|
+
permissionRegistry?: readonly PermissionFeatureLike[];
|
|
85
85
|
/** Cây menu (`src/data/navigations`). Thiếu thì rule nav tự bỏ qua. */
|
|
86
|
-
navigations?: NavigationGroupLike[];
|
|
86
|
+
navigations?: readonly NavigationGroupLike[];
|
|
87
87
|
/**
|
|
88
88
|
* Bộ action ai cũng hiểu. PHẢI trùng danh sách trong `scripts/rbac-sync.ts`
|
|
89
89
|
* của app — lệch nhau thì test xanh mà seed ném lỗi lúc deploy.
|
|
@@ -158,17 +158,26 @@ export type ResolvedGuardrailOptions = GuardrailOptions &
|
|
|
158
158
|
* guardrails không kéo theo phụ thuộc ngược lên tầng config.
|
|
159
159
|
*/
|
|
160
160
|
export interface PermissionFeatureLike {
|
|
161
|
-
resources:
|
|
161
|
+
resources: ReadonlyArray<{
|
|
162
162
|
code: string;
|
|
163
|
-
actions: string[];
|
|
164
|
-
defaultGrants?: Record<string, string[] | "*"
|
|
163
|
+
actions: readonly string[];
|
|
164
|
+
defaultGrants?: Readonly<Record<string, readonly string[] | "*">>;
|
|
165
165
|
}>;
|
|
166
|
-
customActions?:
|
|
166
|
+
customActions?: ReadonlyArray<{ code: string }>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* CHỈ khai trường rule thật sự đọc, KHÔNG index signature: một interface cụ
|
|
171
|
+
* thể (NavigationType của app) gán vào đây được theo structural typing, còn
|
|
172
|
+
* `[key: string]: unknown` thì bắt app phải có index signature — chính là lỗi
|
|
173
|
+
* TS mà template dính ngay sau init (pilot spartronics 2026-08-07).
|
|
174
|
+
*/
|
|
175
|
+
export interface NavigationItemLike {
|
|
176
|
+
resource?: string;
|
|
167
177
|
}
|
|
168
178
|
|
|
169
179
|
export interface NavigationGroupLike {
|
|
170
|
-
items?:
|
|
171
|
-
[key: string]: unknown;
|
|
180
|
+
items?: readonly NavigationItemLike[];
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
/** Kết quả chạy một rule — dùng cho `goerp doctor` (không cần vitest). */
|
|
@@ -20,6 +20,26 @@ export interface BrandThemeColor {
|
|
|
20
20
|
sidebarDark: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Bảng màu thương hiệu CALIBRATE SẴN, khớp 1-1 với swatch trong
|
|
25
|
+
* `configs/themes.ts` — app mới dùng thẳng (theme-provider của starter-app),
|
|
26
|
+
* app muốn nắn tay từng hue (như vinhhoa) thì giữ bảng riêng và bỏ qua cái này.
|
|
27
|
+
*/
|
|
28
|
+
export const THEME_PRESETS: Record<string, BrandThemeColor> = {
|
|
29
|
+
zinc: { primary: "235 12% 27%", primaryDark: "235 10% 62%", fg: "0 0% 98%", sidebar: "235 18% 15%", sidebarDark: "235 16% 9%" },
|
|
30
|
+
slate: { primary: "213 30% 42%", primaryDark: "213 26% 64%", fg: "0 0% 100%", sidebar: "214 36% 19%", sidebarDark: "214 32% 12%" },
|
|
31
|
+
stone: { primary: "26 20% 40%", primaryDark: "28 16% 62%", fg: "0 0% 98%", sidebar: "26 28% 18%", sidebarDark: "26 24% 11%" },
|
|
32
|
+
gray: { primary: "218 9% 48%", primaryDark: "218 9% 68%", fg: "0 0% 100%", sidebar: "218 16% 19%", sidebarDark: "218 14% 12%" },
|
|
33
|
+
neutral: { primary: "0 0% 42%", primaryDark: "0 0% 66%", fg: "0 0% 100%", sidebar: "0 0% 16%", sidebarDark: "0 0% 9%" },
|
|
34
|
+
red: { primary: "4 78% 50%", primaryDark: "6 84% 62%", fg: "0 0% 100%", sidebar: "356 62% 28%", sidebarDark: "356 58% 16%" },
|
|
35
|
+
rose: { primary: "338 80% 53%", primaryDark: "340 80% 63%", fg: "0 0% 100%", sidebar: "338 56% 28%", sidebarDark: "338 54% 16%" },
|
|
36
|
+
orange: { primary: "26 88% 47%", primaryDark: "30 92% 58%", fg: "0 0% 100%", sidebar: "20 75% 28%", sidebarDark: "22 72% 15%" },
|
|
37
|
+
green: { primary: "151 66% 38%", primaryDark: "150 58% 48%", fg: "0 0% 100%", sidebar: "156 70% 16%", sidebarDark: "158 68% 9%" },
|
|
38
|
+
blue: { primary: "233 74% 44%", primaryDark: "230 62% 60%", fg: "0 0% 100%", sidebar: "233.9 88.8% 17.5%", sidebarDark: "234 50% 10%" },
|
|
39
|
+
yellow: { primary: "42 92% 46%", primaryDark: "46 96% 56%", fg: "40 60% 12%", sidebar: "40 72% 25%", sidebarDark: "42 70% 14%" },
|
|
40
|
+
violet: { primary: "262 72% 52%", primaryDark: "263 72% 64%", fg: "0 0% 100%", sidebar: "264 56% 25%", sidebarDark: "265 52% 15%" },
|
|
41
|
+
};
|
|
42
|
+
|
|
23
43
|
/** Dịch lightness của "H S% L%" (cho hover/active). */
|
|
24
44
|
export function shiftLightness(triple: string, delta: number): string {
|
|
25
45
|
const parts = triple.trim().split(/\s+/);
|
|
@@ -567,7 +567,9 @@ export function ResourceCatalogPage({
|
|
|
567
567
|
?.description ||
|
|
568
568
|
actionName(code)
|
|
569
569
|
}
|
|
570
|
-
|
|
570
|
+
// Check màu đã tự nói lên trạng thái — không cần thêm khung
|
|
571
|
+
// viền/nền màu (nhiễu khi cả dãy chip đứng cạnh nhau).
|
|
572
|
+
className="inline-flex items-center gap-1 px-1 py-0.5 text-[11px] font-medium text-foreground"
|
|
571
573
|
>
|
|
572
574
|
<span
|
|
573
575
|
className={cn(
|
|
@@ -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
|
+
}
|