@goplusvn/core 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/guardrails/__tests__/guardrails.test.ts +82 -0
- package/src/guardrails/rules/rbac.ts +120 -0
- package/src/navigation/index.ts +49 -0
- package/src/rbac/__tests__/landing-path.test.ts +148 -0
- package/src/rbac/__tests__/route-handlers.test.ts +147 -0
- package/src/rbac/landing-path.ts +140 -0
- package/src/rbac/pages/role-form-page.tsx +99 -0
- package/src/rbac/route-handlers.ts +22 -1
- package/src/schemas/role.schema.ts +6 -0
- package/src/ui/auth/sign-in-form.tsx +36 -4
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/workspace/__tests__/workspace-delegation.test.ts +1 -1
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +35 -0
- package/src/workspace/__tests__/workspace-service.test.ts +1 -1
- package/src/workspace/components/scope-level-select.tsx +4 -4
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +2 -2
- package/src/workspace/components/workspace-tree-view.tsx +66 -25
- package/src/workspace/delegation.ts +7 -7
- package/src/workspace/index.ts +16 -0
- package/src/workspace/pages/workspace-list-page.tsx +425 -53
- package/src/workspace/route-handlers.ts +278 -2
- package/src/workspace/service.ts +4 -4
- package/src/workspace/tree.ts +1 -1
- package/src/workspace/types.ts +1 -1
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +14 -2
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
2
|
+
|
|
3
|
+
import type { NavigationType } from "../types";
|
|
4
|
+
import type { Session } from "../auth";
|
|
5
|
+
import { checkPermission } from "../auth";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TRANG MỞ ĐẦU của một người — nơi đưa họ tới sau khi đăng nhập, và nơi đá về
|
|
9
|
+
* khi họ mở một trang không có quyền.
|
|
10
|
+
*
|
|
11
|
+
* Mặc định của mọi app là cắm cứng "/" cho tất cả. Hệ quả: người chỉ có một
|
|
12
|
+
* phần việc (đặt cơm, nhận món, chấm công) vẫn bị ném vào bảng số liệu tổng —
|
|
13
|
+
* và khi trang chủ có quyền riêng thì "/" còn là ngõ cụt thật sự với họ.
|
|
14
|
+
*
|
|
15
|
+
* Thứ tự quyết định, dừng ở cái đầu tiên hợp lệ:
|
|
16
|
+
*
|
|
17
|
+
* 1. `landingPath` của vai trò, ưu tiên vai trò MẠNH nhất (`rank` nhỏ nhất) —
|
|
18
|
+
* người kiêm nhiều vai vào trang của vai cao nhất.
|
|
19
|
+
* 2. Mục ĐẦU TIÊN trên menu mà họ mở được, theo đúng thứ tự sidebar.
|
|
20
|
+
* 3. "/" — chịu thua, để trang chủ tự nói là chưa có quyền.
|
|
21
|
+
*
|
|
22
|
+
* Bước 1 LUÔN kiểm tra lại quyền thật. Cấu hình nhập một lần rồi quyền đổi sau
|
|
23
|
+
* đó; một `landingPath` trỏ tới trang người ta không mở được sẽ thành vòng
|
|
24
|
+
* chuyển hướng vô hạn — người dùng chỉ thấy trình duyệt quay mãi.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Chỉ cần đúng một câu hỏi trên bảng `roles` — app truyền client nào cũng được. */
|
|
28
|
+
export interface LandingPrismaClient {
|
|
29
|
+
role: {
|
|
30
|
+
findMany: (args: any) => Promise<Array<{ landingPath?: string | null }>>;
|
|
31
|
+
// `fields` của Prisma là object sinh sẵn theo model, không có index
|
|
32
|
+
// signature — khai lỏng để client thật của app nào cũng khớp.
|
|
33
|
+
fields?: object;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LandingPathDeps {
|
|
38
|
+
prisma: LandingPrismaClient;
|
|
39
|
+
/** Cây menu GỐC của app (chưa lọc quyền) — thứ tự ở đây là thứ tự ưu tiên. */
|
|
40
|
+
navigations: readonly NavigationType[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Chuẩn hoá giá trị người dùng nhập/chọn trước khi ghi DB. Chỉ nhận đường dẫn
|
|
45
|
+
* NỘI BỘ; rỗng hoặc bậy = "tự suy ra" (null).
|
|
46
|
+
*
|
|
47
|
+
* "//host" là URL giao thức tương đối — chặn, nếu không cấu hình vai trò thành
|
|
48
|
+
* chỗ đá người dùng sang tên miền lạ ngay sau khi đăng nhập.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeLandingPath(value: unknown): string | null {
|
|
51
|
+
if (typeof value !== "string") return null;
|
|
52
|
+
const path = value.trim();
|
|
53
|
+
if (!path.startsWith("/") || path.startsWith("//")) return null;
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Leaf {
|
|
58
|
+
href: string;
|
|
59
|
+
resource?: string;
|
|
60
|
+
action?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Mọi mục có `href`, phẳng ra theo đúng thứ tự hiển thị trên sidebar. */
|
|
64
|
+
function menuLeaves(navigations: readonly NavigationType[]): Leaf[] {
|
|
65
|
+
const out: Leaf[] = [];
|
|
66
|
+
const walk = (items: readonly unknown[]) => {
|
|
67
|
+
for (const raw of items) {
|
|
68
|
+
const item = raw as {
|
|
69
|
+
href?: string;
|
|
70
|
+
resource?: string;
|
|
71
|
+
action?: string;
|
|
72
|
+
items?: readonly unknown[];
|
|
73
|
+
};
|
|
74
|
+
if (item.items?.length) {
|
|
75
|
+
walk(item.items);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (item.href) {
|
|
79
|
+
out.push({
|
|
80
|
+
href: item.href,
|
|
81
|
+
resource: item.resource,
|
|
82
|
+
action: item.action,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
for (const group of navigations) walk(group.items ?? []);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Mục không khai `resource` là trang công khai với người đã đăng nhập. */
|
|
92
|
+
function canOpen(session: Session, leaf: Leaf): boolean {
|
|
93
|
+
if (!leaf.resource) return true;
|
|
94
|
+
return checkPermission(session, leaf.resource, leaf.action ?? "view");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const withLocale = (locale: string, href: string) =>
|
|
98
|
+
href === "/" ? `/${locale}` : `/${locale}${href}`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* App nào CHƯA migrate cột `landing_path` thì bỏ qua bước 1, vẫn chạy bình
|
|
102
|
+
* thường bằng mục menu đầu tiên — hỏi cột không tồn tại là Prisma ném thẳng.
|
|
103
|
+
*/
|
|
104
|
+
const hasField = (prisma: LandingPrismaClient, field: string) =>
|
|
105
|
+
Boolean((prisma as any)?.role?.fields?.[field]);
|
|
106
|
+
|
|
107
|
+
const hasLandingPath = (prisma: LandingPrismaClient) =>
|
|
108
|
+
hasField(prisma, "landingPath");
|
|
109
|
+
|
|
110
|
+
export function createLandingPathResolver({
|
|
111
|
+
prisma,
|
|
112
|
+
navigations,
|
|
113
|
+
}: LandingPathDeps) {
|
|
114
|
+
return cache(
|
|
115
|
+
async (session: Session | null, locale: string): Promise<string> => {
|
|
116
|
+
if (!session?.user) return `/${locale}/sign-in`;
|
|
117
|
+
|
|
118
|
+
const leaves = menuLeaves(navigations);
|
|
119
|
+
const roleCodes = ((session.user as any).roles ?? []) as string[];
|
|
120
|
+
|
|
121
|
+
if (roleCodes.length > 0 && hasLandingPath(prisma)) {
|
|
122
|
+
const configured = await prisma.role.findMany({
|
|
123
|
+
where: { code: { in: roleCodes }, landingPath: { not: null } },
|
|
124
|
+
select: { landingPath: true },
|
|
125
|
+
// `rank` nhỏ = quyền lực cao. App nào chưa có cột `rank` (vinhhoa) thì
|
|
126
|
+
// bỏ orderBy — sắp theo cột không tồn tại là Prisma ném.
|
|
127
|
+
...(hasField(prisma, "rank") ? { orderBy: { rank: "asc" } } : {}),
|
|
128
|
+
});
|
|
129
|
+
for (const role of configured) {
|
|
130
|
+
const leaf = leaves.find((l) => l.href === role.landingPath);
|
|
131
|
+
if (leaf && canOpen(session, leaf))
|
|
132
|
+
return withLocale(locale, leaf.href);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const first = leaves.find((l) => canOpen(session, l));
|
|
137
|
+
return withLocale(locale, first?.href ?? "/");
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
ChevronDown,
|
|
43
43
|
ChevronRight,
|
|
44
44
|
Copy,
|
|
45
|
+
House,
|
|
45
46
|
Info,
|
|
46
47
|
LayoutTemplate,
|
|
47
48
|
Save,
|
|
@@ -76,6 +77,9 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
76
77
|
// Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
|
|
77
78
|
const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
78
79
|
|
|
80
|
+
/** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
|
|
81
|
+
const LANDING_AUTO = "__auto__"
|
|
82
|
+
|
|
79
83
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
80
84
|
export interface RoleTemplate {
|
|
81
85
|
code: string
|
|
@@ -136,6 +140,8 @@ export interface RoleFormPageProps {
|
|
|
136
140
|
description: string | undefined
|
|
137
141
|
status: string
|
|
138
142
|
permissions: string[]
|
|
143
|
+
/** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
|
|
144
|
+
landingPath?: string | null
|
|
139
145
|
}
|
|
140
146
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
141
147
|
roleTemplates?: RoleTemplate[]
|
|
@@ -189,6 +195,8 @@ export function RoleFormPage({
|
|
|
189
195
|
description: initialData?.description || "",
|
|
190
196
|
status: (initialData?.status || "active") as "active" | "inactive",
|
|
191
197
|
permissions: initialData?.permissions || ([] as string[]),
|
|
198
|
+
// "" = tự suy ra (mục đầu tiên trên menu mà vai trò này mở được).
|
|
199
|
+
landingPath: initialData?.landingPath || "",
|
|
192
200
|
})
|
|
193
201
|
|
|
194
202
|
const getLocalizedName = React.useCallback(
|
|
@@ -436,6 +444,30 @@ export function RoleFormPage({
|
|
|
436
444
|
})
|
|
437
445
|
}
|
|
438
446
|
|
|
447
|
+
// ── Trang mở đầu của vai trò ──────────────────────────────────────────
|
|
448
|
+
// Chỉ cho chọn trang mà vai trò này THẬT SỰ mở được: đặt trang chưa cấp
|
|
449
|
+
// quyền làm nơi hạ cánh là đá người ta vào tường ngay sau khi đăng nhập.
|
|
450
|
+
const landingOptions = React.useMemo(() => {
|
|
451
|
+
const byHref = new Map<string, { href: string; label: string }>()
|
|
452
|
+
tree.forEach((section) =>
|
|
453
|
+
section.items.forEach((i) => {
|
|
454
|
+
if (!i.href || i.note || !has(i.resource, "view")) return
|
|
455
|
+
// Hai mục cùng href (vd trang chi tiết gắn 2 chỗ) — giữ mục đầu.
|
|
456
|
+
if (!byHref.has(i.href))
|
|
457
|
+
byHref.set(i.href, {
|
|
458
|
+
href: i.href,
|
|
459
|
+
label: `${section.title} · ${i.title}`,
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
)
|
|
463
|
+
return Array.from(byHref.values())
|
|
464
|
+
}, [tree, has])
|
|
465
|
+
|
|
466
|
+
/** Đã cấu hình nhưng quyền bị gỡ sau đó — vẫn hiện để admin thấy mà sửa. */
|
|
467
|
+
const landingStale =
|
|
468
|
+
formData.landingPath !== "" &&
|
|
469
|
+
!landingOptions.some((o) => o.href === formData.landingPath)
|
|
470
|
+
|
|
439
471
|
const applyTemplate = (templateCode: string) => {
|
|
440
472
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
441
473
|
if (!tpl) return
|
|
@@ -854,6 +886,73 @@ export function RoleFormPage({
|
|
|
854
886
|
</div>
|
|
855
887
|
)}
|
|
856
888
|
|
|
889
|
+
{/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
|
|
890
|
+
<div className="flex items-center gap-1.5">
|
|
891
|
+
<House className="h-3.5 w-3.5 text-muted-foreground" />
|
|
892
|
+
<span className="text-xs text-muted-foreground">
|
|
893
|
+
Trang mở đầu:
|
|
894
|
+
</span>
|
|
895
|
+
<Select
|
|
896
|
+
value={formData.landingPath || LANDING_AUTO}
|
|
897
|
+
onValueChange={(v) =>
|
|
898
|
+
setFormData({
|
|
899
|
+
...formData,
|
|
900
|
+
landingPath: v === LANDING_AUTO ? "" : v,
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
>
|
|
904
|
+
<SelectTrigger
|
|
905
|
+
className={cn(
|
|
906
|
+
"h-8 w-52 rounded-md border border-border bg-card text-sm",
|
|
907
|
+
landingStale && "border-amber-500 text-amber-700 dark:text-amber-400"
|
|
908
|
+
)}
|
|
909
|
+
>
|
|
910
|
+
<SelectValue>
|
|
911
|
+
{!formData.landingPath
|
|
912
|
+
? "Tự động"
|
|
913
|
+
: landingStale
|
|
914
|
+
? `${formData.landingPath} · chưa cấp quyền`
|
|
915
|
+
: landingOptions.find(
|
|
916
|
+
(o) => o.href === formData.landingPath
|
|
917
|
+
)?.label}
|
|
918
|
+
</SelectValue>
|
|
919
|
+
</SelectTrigger>
|
|
920
|
+
<SelectContent className="rounded-lg">
|
|
921
|
+
<SelectItem value={LANDING_AUTO}>
|
|
922
|
+
<span className="flex flex-col">
|
|
923
|
+
<span>Tự động</span>
|
|
924
|
+
<span className="text-[11px] text-muted-foreground">
|
|
925
|
+
Menu đầu tiên vai trò này mở được
|
|
926
|
+
</span>
|
|
927
|
+
</span>
|
|
928
|
+
</SelectItem>
|
|
929
|
+
{/* Cấu hình cũ trỏ tới trang vừa bị gỡ quyền — giữ lại trong
|
|
930
|
+
danh sách, nếu không nó biến mất im lặng và admin tưởng
|
|
931
|
+
mình chưa từng đặt. */}
|
|
932
|
+
{landingStale && (
|
|
933
|
+
<SelectItem value={formData.landingPath}>
|
|
934
|
+
<span className="flex flex-col">
|
|
935
|
+
<span>{formData.landingPath}</span>
|
|
936
|
+
<span className="text-[11px] text-amber-600 dark:text-amber-400">
|
|
937
|
+
Chưa cấp quyền vào trang này
|
|
938
|
+
</span>
|
|
939
|
+
</span>
|
|
940
|
+
</SelectItem>
|
|
941
|
+
)}
|
|
942
|
+
{landingOptions.map((o) => (
|
|
943
|
+
<SelectItem key={o.href} value={o.href}>
|
|
944
|
+
<span className="flex flex-col">
|
|
945
|
+
<span>{o.label}</span>
|
|
946
|
+
<span className="text-[11px] text-muted-foreground">
|
|
947
|
+
{o.href}
|
|
948
|
+
</span>
|
|
949
|
+
</span>
|
|
950
|
+
</SelectItem>
|
|
951
|
+
))}
|
|
952
|
+
</SelectContent>
|
|
953
|
+
</Select>
|
|
954
|
+
</div>
|
|
955
|
+
|
|
857
956
|
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
858
957
|
<span className="font-semibold tabular-nums text-foreground">
|
|
859
958
|
{formData.permissions.length}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// });
|
|
15
15
|
|
|
16
16
|
import { getRolesData, type RoleServiceSchema } from "./role-service";
|
|
17
|
+
import { normalizeLandingPath } from "./landing-path";
|
|
17
18
|
import {
|
|
18
19
|
bumpPermissionsVersion,
|
|
19
20
|
getPermissionsVersion,
|
|
@@ -46,6 +47,18 @@ async function writePermissions(tx: any, roleCode: string, permissions: string[]
|
|
|
46
47
|
if (rows.length) await tx.rolePermission.createMany({ data: rows, skipDuplicates: true });
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// ── Trang mở đầu theo vai trò (`Role.landingPath`) ─────────────────────────
|
|
51
|
+
// Cột BỔ SUNG: app nào chưa migrate thì handler phải im lặng bỏ qua, không ném
|
|
52
|
+
// — hai app đang chạy thật vẫn dùng core này. Dò bằng `prisma.role.fields`
|
|
53
|
+
// (Prisma sinh sẵn), rẻ và đúng với schema THẬT của app chứ không đoán.
|
|
54
|
+
const hasLandingPath = (prisma: any) => Boolean(prisma?.role?.fields?.landingPath);
|
|
55
|
+
|
|
56
|
+
/** Mảnh `data` để ghép vào create/update — rỗng khi app chưa có cột. */
|
|
57
|
+
function landingPathData(prisma: any, body: any) {
|
|
58
|
+
if (!hasLandingPath(prisma) || !("landingPath" in (body ?? {}))) return {};
|
|
59
|
+
return { landingPath: normalizeLandingPath(body.landingPath) };
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
// GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
|
|
50
63
|
export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
51
64
|
const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
|
|
@@ -87,7 +100,13 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
87
100
|
const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
|
|
88
101
|
const role = await prisma.$transaction(async (tx: any) => {
|
|
89
102
|
const created = await tx.role.create({
|
|
90
|
-
data: {
|
|
103
|
+
data: {
|
|
104
|
+
code,
|
|
105
|
+
name,
|
|
106
|
+
description: body.description ?? null,
|
|
107
|
+
status: body.status ?? "active",
|
|
108
|
+
...landingPathData(prisma, body),
|
|
109
|
+
},
|
|
91
110
|
});
|
|
92
111
|
await writePermissions(tx, created.code, permissions);
|
|
93
112
|
return created;
|
|
@@ -126,6 +145,7 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
126
145
|
name: role.name,
|
|
127
146
|
description: role.description ?? "",
|
|
128
147
|
status: role.status,
|
|
148
|
+
landingPath: role.landingPath ?? null,
|
|
129
149
|
permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
|
|
130
150
|
});
|
|
131
151
|
} catch (e) {
|
|
@@ -151,6 +171,7 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
151
171
|
name: (body.name ?? existing.name).trim(),
|
|
152
172
|
description: body.description ?? existing.description,
|
|
153
173
|
status: body.status ?? existing.status,
|
|
174
|
+
...landingPathData(prisma, body),
|
|
154
175
|
},
|
|
155
176
|
});
|
|
156
177
|
await writePermissions(tx, updated.code, permissions);
|
|
@@ -6,6 +6,12 @@ export const roleSchema = z.object({
|
|
|
6
6
|
description: z.string().optional(),
|
|
7
7
|
status: z.enum(["active", "inactive"]).default("active"),
|
|
8
8
|
permissions: z.array(z.string()).optional(),
|
|
9
|
+
/**
|
|
10
|
+
* Trang mở đầu của vai trò (`Role.landingPath`). Rỗng = "tự suy ra".
|
|
11
|
+
* Route phải lọc lại bằng `normalizeLandingPath` (@goerp/core/rbac/landing-path)
|
|
12
|
+
* trước khi ghi — schema chỉ nhận kiểu, không phán đường dẫn có an toàn không.
|
|
13
|
+
*/
|
|
14
|
+
landingPath: z.string().optional().nullable(),
|
|
9
15
|
});
|
|
10
16
|
|
|
11
17
|
export type RoleFormData = z.infer<typeof roleSchema>;
|
|
@@ -47,6 +47,18 @@ export interface SignInFormProps {
|
|
|
47
47
|
/** Ghi đè nhãn ô định danh (vd "Email hoặc mã nhân viên"). */
|
|
48
48
|
identifierLabel?: string;
|
|
49
49
|
identifierPlaceholder?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Endpoint trả `{ path }` — TRANG MỞ ĐẦU của chính người vừa đăng nhập.
|
|
52
|
+
*
|
|
53
|
+
* Chỉ hỏi được SAU khi đăng nhập xong (trước đó server chưa biết là ai), nên
|
|
54
|
+
* không thể gói vào `?redirectTo=`. Không khai prop này thì mọi thứ y như cũ:
|
|
55
|
+
* về `?redirectTo=` / NEXT_PUBLIC_HOME_PATHNAME / "/".
|
|
56
|
+
*
|
|
57
|
+
* Hỏng mạng hay trả bậy đều bỏ qua, vẫn về đường mặc định — chặn đăng nhập
|
|
58
|
+
* thành công rồi kẹt ở màn hình trắng là cái giá quá đắt cho một gợi ý điều
|
|
59
|
+
* hướng.
|
|
60
|
+
*/
|
|
61
|
+
landingEndpoint?: string;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
const IDENTIFIER_PRESETS = {
|
|
@@ -80,6 +92,7 @@ export function SignInForm({
|
|
|
80
92
|
identifier = "email",
|
|
81
93
|
identifierLabel,
|
|
82
94
|
identifierPlaceholder,
|
|
95
|
+
landingEndpoint,
|
|
83
96
|
}: SignInFormProps = {}) {
|
|
84
97
|
const preset = IDENTIFIER_PRESETS[identifier];
|
|
85
98
|
const schema = React.useMemo(
|
|
@@ -98,10 +111,9 @@ export function SignInForm({
|
|
|
98
111
|
const { clearAllCache } = useTabContentCache();
|
|
99
112
|
const { clearTabs } = useTabNavigation();
|
|
100
113
|
|
|
114
|
+
const explicitRedirect = searchParams.get("redirectTo");
|
|
101
115
|
const redirectPathname =
|
|
102
|
-
|
|
103
|
-
process.env.NEXT_PUBLIC_HOME_PATHNAME ||
|
|
104
|
-
"/";
|
|
116
|
+
explicitRedirect || process.env.NEXT_PUBLIC_HOME_PATHNAME || "/";
|
|
105
117
|
|
|
106
118
|
const form = useForm<SignInFormType>({
|
|
107
119
|
resolver: zodResolver(schema),
|
|
@@ -145,7 +157,27 @@ export function SignInForm({
|
|
|
145
157
|
// Best-effort only
|
|
146
158
|
}
|
|
147
159
|
|
|
148
|
-
|
|
160
|
+
// `?redirectTo=` là chỗ người dùng đang muốn tới trước khi bị đòi đăng
|
|
161
|
+
// nhập — luôn thắng trang mở đầu của vai trò.
|
|
162
|
+
let target = redirectPathname;
|
|
163
|
+
if (landingEndpoint && !explicitRedirect) {
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetch(landingEndpoint);
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
// Chỉ nhận đường dẫn nội bộ ("//host" là URL giao thức tương đối).
|
|
168
|
+
if (
|
|
169
|
+
typeof data?.path === "string" &&
|
|
170
|
+
data.path.startsWith("/") &&
|
|
171
|
+
!data.path.startsWith("//")
|
|
172
|
+
) {
|
|
173
|
+
target = data.path;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Giữ đường mặc định — đăng nhập đã thành công rồi.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
router.push(target);
|
|
149
181
|
} catch (error) {
|
|
150
182
|
const rawMessage =
|
|
151
183
|
error instanceof Error ? error.message : "Đăng nhập thất bại";
|
|
@@ -52,6 +52,25 @@ interface UnifiedProfileDialogProps {
|
|
|
52
52
|
onSaveProfile?: (data: any, id?: string) => Promise<void>;
|
|
53
53
|
onSaveRoles?: (userId: string, roleCodes: string[]) => Promise<void>;
|
|
54
54
|
onSavePassword?: (userId: string, password: string) => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Bật ô chọn KHÔNG GIAN LÀM VIỆC bằng cách trỏ tới API cây không gian
|
|
57
|
+
* (thường `/api/workspaces`). Bỏ trống ⇒ không fetch, không render, hộp thoại
|
|
58
|
+
* y hệt trước — app chưa bật tính năng workspace (vinhhoa) không đổi một pixel.
|
|
59
|
+
*/
|
|
60
|
+
workspaceApiUrl?: string;
|
|
61
|
+
/** Nhãn theo nghiệp vụ của app: "Đơn vị", "Tổ chức", "Chi nhánh"… */
|
|
62
|
+
workspaceLabel?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Cây → danh sách phẳng, giữ thứ tự duyệt để dòng con nằm ngay dưới dòng cha. */
|
|
66
|
+
function flattenWorkspaceTree(
|
|
67
|
+
nodes: any[],
|
|
68
|
+
depth = 0,
|
|
69
|
+
): { id: string; name: string; depth: number }[] {
|
|
70
|
+
return (nodes ?? []).flatMap((node) => [
|
|
71
|
+
{ id: node.id, name: node.name, depth },
|
|
72
|
+
...flattenWorkspaceTree(node.children ?? [], depth + 1),
|
|
73
|
+
]);
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
const fetcher = async (url: string) => {
|
|
@@ -87,6 +106,8 @@ export function UnifiedProfileDialog({
|
|
|
87
106
|
onSaveProfile,
|
|
88
107
|
onSaveRoles,
|
|
89
108
|
onSavePassword,
|
|
109
|
+
workspaceApiUrl,
|
|
110
|
+
workspaceLabel = "Không gian làm việc",
|
|
90
111
|
}: UnifiedProfileDialogProps) {
|
|
91
112
|
// -- Data Fetching --
|
|
92
113
|
const { data: departments } = useSWR<{ id: string; name: string }[]>(
|
|
@@ -109,6 +130,16 @@ export function UnifiedProfileDialog({
|
|
|
109
130
|
"/api/branches",
|
|
110
131
|
fetcher,
|
|
111
132
|
);
|
|
133
|
+
// Key `null` ⇒ SWR không gọi gì cả. App chưa bật workspace không phát sinh
|
|
134
|
+
// thêm một request nào.
|
|
135
|
+
const { data: workspaceTree } = useSWR<any[]>(
|
|
136
|
+
workspaceApiUrl ?? null,
|
|
137
|
+
fetcher,
|
|
138
|
+
);
|
|
139
|
+
const workspaces = useMemo(
|
|
140
|
+
() => flattenWorkspaceTree(workspaceTree ?? []),
|
|
141
|
+
[workspaceTree],
|
|
142
|
+
);
|
|
112
143
|
|
|
113
144
|
// -- State --
|
|
114
145
|
const [activeSection, setActiveSection] = useState("general");
|
|
@@ -121,6 +152,10 @@ export function UnifiedProfileDialog({
|
|
|
121
152
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
|
122
153
|
const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
|
|
123
154
|
const [defaultBranchId, setDefaultBranchId] = useState<string | null>(null);
|
|
155
|
+
const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>([]);
|
|
156
|
+
const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(
|
|
157
|
+
null,
|
|
158
|
+
);
|
|
124
159
|
const [passwordData, setPasswordData] = useState({
|
|
125
160
|
password: "",
|
|
126
161
|
confirm: "",
|
|
@@ -193,6 +228,8 @@ export function UnifiedProfileDialog({
|
|
|
193
228
|
setSelectedRoles([]);
|
|
194
229
|
setSelectedBranches([]);
|
|
195
230
|
setDefaultBranchId(null);
|
|
231
|
+
setSelectedWorkspaces([]);
|
|
232
|
+
setDefaultWorkspaceId(null);
|
|
196
233
|
setPasswordData({ password: "", confirm: "" });
|
|
197
234
|
setEnableAccount(false);
|
|
198
235
|
} else {
|
|
@@ -226,6 +263,10 @@ export function UnifiedProfileDialog({
|
|
|
226
263
|
setDefaultBranchId(
|
|
227
264
|
d.defaultBranchId || (d.branchIds && d.branchIds[0]) || null,
|
|
228
265
|
);
|
|
266
|
+
setSelectedWorkspaces(d.workspaceIds || []);
|
|
267
|
+
setDefaultWorkspaceId(
|
|
268
|
+
d.defaultWorkspaceId || (d.workspaceIds && d.workspaceIds[0]) || null,
|
|
269
|
+
);
|
|
229
270
|
setPasswordData({ password: "", confirm: "" });
|
|
230
271
|
// Enable account if user has roles or if it's not a customer (employees always have accounts?)
|
|
231
272
|
// For now, if roles exist, we assume account is enabled.
|
|
@@ -245,6 +286,38 @@ export function UnifiedProfileDialog({
|
|
|
245
286
|
}
|
|
246
287
|
}, [open, branches, mode, data]);
|
|
247
288
|
|
|
289
|
+
// Chỉ có đúng một không gian để chọn thì chọn sẵn — cùng cách cư xử với ô chi
|
|
290
|
+
// nhánh ngay bên cạnh, đỡ một cú bấm bắt buộc mà không có lựa chọn nào khác.
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (!open || !workspaceApiUrl || workspaces.length !== 1) return;
|
|
293
|
+
if (mode === "create" && selectedWorkspaces.length === 0) {
|
|
294
|
+
setSelectedWorkspaces([workspaces[0].id]);
|
|
295
|
+
setDefaultWorkspaceId(workspaces[0].id);
|
|
296
|
+
}
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
298
|
+
}, [open, workspaces, mode, workspaceApiUrl]);
|
|
299
|
+
|
|
300
|
+
// -- Workspace selection helpers — giữ đúng khuôn của cặp hàm chi nhánh dưới đây.
|
|
301
|
+
const toggleWorkspace = (workspaceId: string) => {
|
|
302
|
+
if (selectedWorkspaces.includes(workspaceId)) {
|
|
303
|
+
const next = selectedWorkspaces.filter((id) => id !== workspaceId);
|
|
304
|
+
setSelectedWorkspaces(next);
|
|
305
|
+
if (defaultWorkspaceId === workspaceId) {
|
|
306
|
+
setDefaultWorkspaceId(next[0] ?? null);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
setSelectedWorkspaces([...selectedWorkspaces, workspaceId]);
|
|
310
|
+
if (!defaultWorkspaceId) setDefaultWorkspaceId(workspaceId);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const markDefaultWorkspace = (workspaceId: string) => {
|
|
315
|
+
if (!selectedWorkspaces.includes(workspaceId)) {
|
|
316
|
+
setSelectedWorkspaces((prev) => [...prev, workspaceId]);
|
|
317
|
+
}
|
|
318
|
+
setDefaultWorkspaceId(workspaceId);
|
|
319
|
+
};
|
|
320
|
+
|
|
248
321
|
// -- Branch selection helpers --
|
|
249
322
|
// Toggle a branch's membership while keeping a valid default branch.
|
|
250
323
|
const toggleBranch = (branchId: string) => {
|
|
@@ -311,6 +384,16 @@ export function UnifiedProfileDialog({
|
|
|
311
384
|
: (selectedBranches[0] ?? null),
|
|
312
385
|
};
|
|
313
386
|
|
|
387
|
+
// Chỉ gửi khi app bật tính năng — gửi mảng rỗng lên app chưa bật là mời
|
|
388
|
+
// route xoá sạch membership của người đang sửa.
|
|
389
|
+
if (workspaceApiUrl) {
|
|
390
|
+
payload.workspaceIds = selectedWorkspaces;
|
|
391
|
+
payload.defaultWorkspaceId =
|
|
392
|
+
defaultWorkspaceId && selectedWorkspaces.includes(defaultWorkspaceId)
|
|
393
|
+
? defaultWorkspaceId
|
|
394
|
+
: (selectedWorkspaces[0] ?? null);
|
|
395
|
+
}
|
|
396
|
+
|
|
314
397
|
// Handle Password for Customer
|
|
315
398
|
if (isCustomer) {
|
|
316
399
|
if (enableAccount) {
|
|
@@ -943,6 +1026,83 @@ export function UnifiedProfileDialog({
|
|
|
943
1026
|
})}
|
|
944
1027
|
</div>
|
|
945
1028
|
</div>
|
|
1029
|
+
|
|
1030
|
+
{workspaceApiUrl ? (
|
|
1031
|
+
<div className="col-span-2 space-y-3 pt-2">
|
|
1032
|
+
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
1033
|
+
<Label className="text-[13px] font-medium text-foreground">
|
|
1034
|
+
{workspaceLabel}
|
|
1035
|
+
</Label>
|
|
1036
|
+
<span className="inline-flex items-center gap-1 text-[12px] text-muted-foreground">
|
|
1037
|
+
<Star className="h-3 w-3" />
|
|
1038
|
+
Đánh dấu một mục mặc định
|
|
1039
|
+
</span>
|
|
1040
|
+
</div>
|
|
1041
|
+
{workspaces.length === 0 ? (
|
|
1042
|
+
// Rỗng ở đây gần như luôn là "ngoài phạm vi của bạn",
|
|
1043
|
+
// không phải "hệ thống chưa có" — nói rõ để khỏi
|
|
1044
|
+
// tưởng mất dữ liệu.
|
|
1045
|
+
<p className="text-[13px] text-muted-foreground">
|
|
1046
|
+
Không có mục nào trong phạm vi của bạn.
|
|
1047
|
+
</p>
|
|
1048
|
+
) : (
|
|
1049
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
1050
|
+
{workspaces.map((ws) => {
|
|
1051
|
+
const isSelected = selectedWorkspaces.includes(
|
|
1052
|
+
ws.id,
|
|
1053
|
+
);
|
|
1054
|
+
const isDefault =
|
|
1055
|
+
isSelected && defaultWorkspaceId === ws.id;
|
|
1056
|
+
return (
|
|
1057
|
+
<div
|
|
1058
|
+
key={ws.id}
|
|
1059
|
+
className={cn(
|
|
1060
|
+
"flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-accent/50 transition-colors",
|
|
1061
|
+
isSelected
|
|
1062
|
+
? "border-primary bg-primary/5"
|
|
1063
|
+
: "bg-background border-border",
|
|
1064
|
+
)}
|
|
1065
|
+
onClick={() => toggleWorkspace(ws.id)}
|
|
1066
|
+
>
|
|
1067
|
+
<Checkbox
|
|
1068
|
+
checked={isSelected}
|
|
1069
|
+
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
|
1070
|
+
/>
|
|
1071
|
+
<span
|
|
1072
|
+
className="flex-1 text-sm font-medium text-foreground truncate"
|
|
1073
|
+
// Thụt theo cấp để dòng con đọc ra là con
|
|
1074
|
+
// của dòng ngay trên nó.
|
|
1075
|
+
style={{ paddingLeft: ws.depth * 12 }}
|
|
1076
|
+
>
|
|
1077
|
+
{ws.name}
|
|
1078
|
+
</span>
|
|
1079
|
+
{isSelected &&
|
|
1080
|
+
(isDefault ? (
|
|
1081
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground shrink-0">
|
|
1082
|
+
<Star className="h-3 w-3 fill-current" />
|
|
1083
|
+
Mặc định
|
|
1084
|
+
</span>
|
|
1085
|
+
) : (
|
|
1086
|
+
<button
|
|
1087
|
+
type="button"
|
|
1088
|
+
onClick={(e) => {
|
|
1089
|
+
e.stopPropagation();
|
|
1090
|
+
markDefaultWorkspace(ws.id);
|
|
1091
|
+
}}
|
|
1092
|
+
title="Đặt làm mặc định"
|
|
1093
|
+
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground hover:border-primary/50 hover:text-primary transition-colors shrink-0"
|
|
1094
|
+
>
|
|
1095
|
+
<Star className="h-3 w-3" />
|
|
1096
|
+
Đặt mặc định
|
|
1097
|
+
</button>
|
|
1098
|
+
))}
|
|
1099
|
+
</div>
|
|
1100
|
+
);
|
|
1101
|
+
})}
|
|
1102
|
+
</div>
|
|
1103
|
+
)}
|
|
1104
|
+
</div>
|
|
1105
|
+
) : null}
|
|
946
1106
|
</div>
|
|
947
1107
|
</div>
|
|
948
1108
|
)}
|
|
@@ -37,6 +37,12 @@ interface UsersClientPageProps {
|
|
|
37
37
|
/** Cây menu + meta action cho dialog "Quyền hiệu lực" (additive). */
|
|
38
38
|
menuTree?: EffectiveMenuTreeSection[];
|
|
39
39
|
actionMeta?: Record<string, { label?: string; flow?: string; description?: string }>;
|
|
40
|
+
/**
|
|
41
|
+
* Bật ô chọn không gian làm việc trong hộp thoại người dùng (additive). Bỏ
|
|
42
|
+
* trống ⇒ hộp thoại giữ nguyên như cũ, không fetch gì thêm.
|
|
43
|
+
*/
|
|
44
|
+
workspaceApiUrl?: string;
|
|
45
|
+
workspaceLabel?: string;
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
const DEFAULT_PAGE_SIZE = 20;
|
|
@@ -52,6 +58,8 @@ export function UsersClientPage({
|
|
|
52
58
|
onResetPassword,
|
|
53
59
|
menuTree,
|
|
54
60
|
actionMeta,
|
|
61
|
+
workspaceApiUrl,
|
|
62
|
+
workspaceLabel,
|
|
55
63
|
}: UsersClientPageProps) {
|
|
56
64
|
const [permUser, setPermUser] = useState<any | null>(null);
|
|
57
65
|
const router = useRouter();
|
|
@@ -282,6 +290,8 @@ export function UsersClientPage({
|
|
|
282
290
|
mode="create"
|
|
283
291
|
viewMode="admin"
|
|
284
292
|
roles={roles}
|
|
293
|
+
workspaceApiUrl={workspaceApiUrl}
|
|
294
|
+
workspaceLabel={workspaceLabel}
|
|
285
295
|
onSaveProfile={(data) => handleUserSubmit(data)}
|
|
286
296
|
/>
|
|
287
297
|
|
|
@@ -294,6 +304,8 @@ export function UsersClientPage({
|
|
|
294
304
|
mode="edit"
|
|
295
305
|
viewMode="admin"
|
|
296
306
|
roles={roles}
|
|
307
|
+
workspaceApiUrl={workspaceApiUrl}
|
|
308
|
+
workspaceLabel={workspaceLabel}
|
|
297
309
|
onSaveProfile={handleUserSubmit}
|
|
298
310
|
onSaveRoles={handleAssignRoles}
|
|
299
311
|
onSavePassword={handleResetPassword}
|