@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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SessionsPage } from "@goerp/core/security"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: "Quản lý phiên đăng nhập" }
|
|
8
|
+
export const dynamic = "force-dynamic"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Trang QUẢN TRỊ — quản lý phiên đăng nhập (Better Auth) của MỌI user.
|
|
12
|
+
* Màn hình sẵn của core; app chỉ gác quyền `user:view` (cùng resource với
|
|
13
|
+
* trang Người dùng) và cung cấp 3 endpoint /api/security/sessions.
|
|
14
|
+
*/
|
|
15
|
+
export default async function SecuritySessionsPage({
|
|
16
|
+
params,
|
|
17
|
+
}: {
|
|
18
|
+
params: Promise<{ lang: string }>
|
|
19
|
+
}) {
|
|
20
|
+
const { lang } = await params
|
|
21
|
+
await requirePageAccess(lang, "user")
|
|
22
|
+
|
|
23
|
+
return <SessionsPage />
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SystemCategoryPage } from "@goerp/core/system/pages/system-category-page"
|
|
2
|
+
import { getSystemCategoryGroupsData } from "@goerp/core/system/services/system-category-service"
|
|
3
|
+
|
|
4
|
+
import type { Metadata } from "next"
|
|
5
|
+
|
|
6
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
7
|
+
import { db } from "@/lib/prisma"
|
|
8
|
+
|
|
9
|
+
export const metadata: Metadata = { title: "Danh mục hệ thống" }
|
|
10
|
+
export const dynamic = "force-dynamic"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Danh mục hệ thống — màn hình sẵn của core (sidebar nhóm + danh sách mục).
|
|
14
|
+
* Trang chỉ nạp danh sách nhóm ban đầu; mọi thao tác CRUD sau đó component
|
|
15
|
+
* core tự gọi /api/system-categories + /api/system-category-groups.
|
|
16
|
+
*/
|
|
17
|
+
export default async function SystemCategoriesPage({
|
|
18
|
+
params,
|
|
19
|
+
}: {
|
|
20
|
+
params: Promise<{ lang: string }>
|
|
21
|
+
}) {
|
|
22
|
+
const { lang } = await params
|
|
23
|
+
await requirePageAccess(lang, "system-category")
|
|
24
|
+
|
|
25
|
+
const { items: initialGroups } = await getSystemCategoryGroupsData(db, {
|
|
26
|
+
pageSize: 100, // nạp hết nhóm cho sidebar
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
return <SystemCategoryPage initialGroups={initialGroups} />
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use server"
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
|
|
6
|
+
import { getAuth } from "@/lib/better-auth"
|
|
7
|
+
|
|
8
|
+
const schema = z
|
|
9
|
+
.object({
|
|
10
|
+
currentPassword: z.string().min(1, "Nhập mật khẩu hiện tại"),
|
|
11
|
+
newPassword: z.string().min(6, "Mật khẩu mới tối thiểu 6 ký tự"),
|
|
12
|
+
confirmPassword: z.string().min(1, "Nhập lại mật khẩu mới"),
|
|
13
|
+
})
|
|
14
|
+
.refine((d) => d.newPassword === d.confirmPassword, {
|
|
15
|
+
message: "Mật khẩu nhập lại không khớp",
|
|
16
|
+
path: ["confirmPassword"],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export interface ChangePasswordState {
|
|
20
|
+
success?: boolean
|
|
21
|
+
message?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Đổi mật khẩu của CHÍNH người đang đăng nhập — đi qua Better Auth
|
|
26
|
+
* `changePassword` (tự verify mật khẩu cũ + hash bằng bcrypt đã cấu hình +
|
|
27
|
+
* thu hồi các phiên khác), không tự chế so bcrypt ở app.
|
|
28
|
+
*/
|
|
29
|
+
export async function changePassword(
|
|
30
|
+
_prev: ChangePasswordState,
|
|
31
|
+
formData: FormData,
|
|
32
|
+
): Promise<ChangePasswordState> {
|
|
33
|
+
// Không getSession() trần ở đây (guardrail server-action-bare-session):
|
|
34
|
+
// auth.api.changePassword tự validate phiên từ headers — không phiên là ném.
|
|
35
|
+
const parsed = schema.safeParse({
|
|
36
|
+
currentPassword: formData.get("currentPassword"),
|
|
37
|
+
newPassword: formData.get("newPassword"),
|
|
38
|
+
confirmPassword: formData.get("confirmPassword"),
|
|
39
|
+
})
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
return { message: parsed.error.issues[0]?.message ?? "Dữ liệu không hợp lệ" }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await getAuth().api.changePassword({
|
|
46
|
+
headers: await headers(),
|
|
47
|
+
body: {
|
|
48
|
+
currentPassword: parsed.data.currentPassword,
|
|
49
|
+
newPassword: parsed.data.newPassword,
|
|
50
|
+
revokeOtherSessions: true,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
return { success: true, message: "Đã đổi mật khẩu." }
|
|
54
|
+
} catch {
|
|
55
|
+
return {
|
|
56
|
+
message:
|
|
57
|
+
"Không đổi được mật khẩu — kiểm tra mật khẩu hiện tại, hoặc đăng nhập lại nếu phiên đã hết hạn.",
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { redirect } from "next/navigation"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { i18n } from "@/configs/i18n"
|
|
6
|
+
import { getSession } from "@/lib/auth"
|
|
7
|
+
import { db } from "@/lib/prisma"
|
|
8
|
+
|
|
9
|
+
import { ProfileClientPage } from "./profile-client-page"
|
|
10
|
+
|
|
11
|
+
export const metadata: Metadata = { title: "Hồ sơ cá nhân" }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hồ sơ NGƯỜI ĐANG ĐĂNG NHẬP — đích của mục "Hồ sơ" trong dropdown user của
|
|
15
|
+
* core MainLayout (/user/profile). Không cần quyền gì ngoài phiên hợp lệ:
|
|
16
|
+
* ai cũng được xem thông tin của chính mình.
|
|
17
|
+
*/
|
|
18
|
+
export default async function UserProfilePage() {
|
|
19
|
+
const session = await getSession()
|
|
20
|
+
if (!session?.user?.id) redirect(`/${i18n.defaultLocale}/sign-in`)
|
|
21
|
+
|
|
22
|
+
const user = await db.user.findUnique({
|
|
23
|
+
where: { id: session.user.id },
|
|
24
|
+
select: {
|
|
25
|
+
id: true,
|
|
26
|
+
name: true,
|
|
27
|
+
email: true,
|
|
28
|
+
image: true,
|
|
29
|
+
createdAt: true,
|
|
30
|
+
userRoles: { select: { roleCode: true, role: { select: { name: true } } } },
|
|
31
|
+
userBranches: {
|
|
32
|
+
select: { isDefault: true, branch: { select: { name: true } } },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
if (!user) redirect(`/${i18n.defaultLocale}`)
|
|
37
|
+
|
|
38
|
+
return <ProfileClientPage user={user} />
|
|
39
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useActionState } from "react"
|
|
5
|
+
import { toast } from "sonner"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Avatar,
|
|
9
|
+
AvatarFallback,
|
|
10
|
+
AvatarImage,
|
|
11
|
+
Badge,
|
|
12
|
+
Card,
|
|
13
|
+
CardContent,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
} from "@goerp/core/ui"
|
|
17
|
+
import { Button } from "@goerp/core/ui/primitives/button"
|
|
18
|
+
import { Input } from "@goerp/core/ui/primitives/input"
|
|
19
|
+
import { Label } from "@goerp/core/ui/primitives/label"
|
|
20
|
+
|
|
21
|
+
import { changePassword, type ChangePasswordState } from "./actions"
|
|
22
|
+
|
|
23
|
+
interface ProfileUser {
|
|
24
|
+
id: string
|
|
25
|
+
name: string | null
|
|
26
|
+
email: string
|
|
27
|
+
image: string | null
|
|
28
|
+
createdAt: Date
|
|
29
|
+
userRoles: { roleCode: string; role: { name: string } }[]
|
|
30
|
+
userBranches: { isDefault: boolean; branch: { name: string } }[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ProfileClientPage({ user }: { user: ProfileUser }) {
|
|
34
|
+
const [state, formAction, pending] = useActionState<
|
|
35
|
+
ChangePasswordState,
|
|
36
|
+
FormData
|
|
37
|
+
>(changePassword, {})
|
|
38
|
+
const formRef = React.useRef<HTMLFormElement>(null)
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!state.message) return
|
|
42
|
+
if (state.success) {
|
|
43
|
+
toast.success(state.message)
|
|
44
|
+
formRef.current?.reset()
|
|
45
|
+
} else {
|
|
46
|
+
toast.error(state.message)
|
|
47
|
+
}
|
|
48
|
+
}, [state])
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 md:p-6">
|
|
52
|
+
<h1 className="text-2xl font-bold tracking-tight">Hồ sơ cá nhân</h1>
|
|
53
|
+
|
|
54
|
+
<Card>
|
|
55
|
+
<CardHeader>
|
|
56
|
+
<CardTitle>Thông tin chung</CardTitle>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
<CardContent className="flex flex-col gap-6">
|
|
59
|
+
<div className="flex items-center gap-4">
|
|
60
|
+
<Avatar className="size-14">
|
|
61
|
+
<AvatarImage src={user.image ?? undefined} alt={user.name ?? ""} />
|
|
62
|
+
<AvatarFallback>
|
|
63
|
+
{(user.name ?? user.email).slice(0, 2).toUpperCase()}
|
|
64
|
+
</AvatarFallback>
|
|
65
|
+
</Avatar>
|
|
66
|
+
<div>
|
|
67
|
+
<p className="font-medium">{user.name ?? "—"}</p>
|
|
68
|
+
<p className="text-sm text-muted-foreground">{user.email}</p>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="grid gap-6 md:grid-cols-2">
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
<Label>Vai trò</Label>
|
|
74
|
+
<div className="flex flex-wrap gap-2">
|
|
75
|
+
{user.userRoles.length ? (
|
|
76
|
+
user.userRoles.map((ur) => (
|
|
77
|
+
<Badge key={ur.roleCode} variant="secondary">
|
|
78
|
+
{ur.role.name}
|
|
79
|
+
</Badge>
|
|
80
|
+
))
|
|
81
|
+
) : (
|
|
82
|
+
<span className="text-sm text-muted-foreground">—</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="space-y-2">
|
|
87
|
+
<Label>Chi nhánh</Label>
|
|
88
|
+
<div className="flex flex-wrap gap-2">
|
|
89
|
+
{user.userBranches.length ? (
|
|
90
|
+
user.userBranches.map((ub, i) => (
|
|
91
|
+
<Badge key={i} variant="secondary">
|
|
92
|
+
{ub.branch.name}
|
|
93
|
+
{ub.isDefault ? " (mặc định)" : ""}
|
|
94
|
+
</Badge>
|
|
95
|
+
))
|
|
96
|
+
) : (
|
|
97
|
+
<span className="text-sm text-muted-foreground">—</span>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</CardContent>
|
|
103
|
+
</Card>
|
|
104
|
+
|
|
105
|
+
<Card>
|
|
106
|
+
<CardHeader>
|
|
107
|
+
<CardTitle>Đổi mật khẩu</CardTitle>
|
|
108
|
+
</CardHeader>
|
|
109
|
+
<CardContent>
|
|
110
|
+
<form ref={formRef} action={formAction} className="grid gap-4 md:max-w-md">
|
|
111
|
+
<div className="space-y-2">
|
|
112
|
+
<Label htmlFor="currentPassword">Mật khẩu hiện tại</Label>
|
|
113
|
+
<Input
|
|
114
|
+
id="currentPassword"
|
|
115
|
+
name="currentPassword"
|
|
116
|
+
type="password"
|
|
117
|
+
autoComplete="current-password"
|
|
118
|
+
required
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="space-y-2">
|
|
122
|
+
<Label htmlFor="newPassword">Mật khẩu mới</Label>
|
|
123
|
+
<Input
|
|
124
|
+
id="newPassword"
|
|
125
|
+
name="newPassword"
|
|
126
|
+
type="password"
|
|
127
|
+
autoComplete="new-password"
|
|
128
|
+
required
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div className="space-y-2">
|
|
132
|
+
<Label htmlFor="confirmPassword">Nhập lại mật khẩu mới</Label>
|
|
133
|
+
<Input
|
|
134
|
+
id="confirmPassword"
|
|
135
|
+
name="confirmPassword"
|
|
136
|
+
type="password"
|
|
137
|
+
autoComplete="new-password"
|
|
138
|
+
required
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<Button type="submit" disabled={pending} className="w-fit">
|
|
142
|
+
{pending ? "Đang cập nhật..." : "Đổi mật khẩu"}
|
|
143
|
+
</Button>
|
|
144
|
+
</form>
|
|
145
|
+
</CardContent>
|
|
146
|
+
</Card>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getCrudPermissions } from "@goerp/core/crud/server"
|
|
2
|
+
import { getActiveRoles, getUserStats } from "@goerp/core/user"
|
|
3
|
+
import { UsersClientPage } from "@goerp/core/user/client-page"
|
|
4
|
+
|
|
5
|
+
import type { Metadata } from "next"
|
|
6
|
+
|
|
7
|
+
import { usersConfig } from "@/configs/entities/users.config"
|
|
8
|
+
import { getActionMetaMap } from "@/configs/permissions"
|
|
9
|
+
import { MENU_TREE } from "@/configs/permissions/menu-tree"
|
|
10
|
+
import { dictionary } from "@/data/dictionary"
|
|
11
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
12
|
+
import { db } from "@/lib/prisma"
|
|
13
|
+
import { getUsersList } from "@/server/services/user-service"
|
|
14
|
+
|
|
15
|
+
export const metadata: Metadata = { title: "Người dùng" }
|
|
16
|
+
export const dynamic = "force-dynamic"
|
|
17
|
+
|
|
18
|
+
interface UsersPageProps {
|
|
19
|
+
params: Promise<{ lang: string }>
|
|
20
|
+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Quản lý người dùng — màn hình sẵn của core (UsersClientPage), trang chỉ nạp
|
|
25
|
+
* dữ liệu và truyền props. Danh sách lọc theo ?search&role&status trên URL
|
|
26
|
+
* (toolbar của core ghi query), phân trang chạy phía client.
|
|
27
|
+
*
|
|
28
|
+
* Tạo/sửa đi qua /api/users (POST/PUT) — UnifiedProfileDialog của core tự gọi;
|
|
29
|
+
* không truyền onAssignRoles/onResetPassword vì vai trò + mật khẩu đã nằm
|
|
30
|
+
* trong payload PUT (dialog gửi cả roleCodes/branchIds/password một lượt).
|
|
31
|
+
*/
|
|
32
|
+
export default async function UsersPage({
|
|
33
|
+
params,
|
|
34
|
+
searchParams,
|
|
35
|
+
}: UsersPageProps) {
|
|
36
|
+
const { lang } = await params
|
|
37
|
+
const session = await requirePageAccess(lang, "user")
|
|
38
|
+
|
|
39
|
+
const sp = await searchParams
|
|
40
|
+
const search = typeof sp.search === "string" ? sp.search.trim() : undefined
|
|
41
|
+
const role = typeof sp.role === "string" ? sp.role : undefined
|
|
42
|
+
const status = typeof sp.status === "string" ? sp.status : undefined
|
|
43
|
+
|
|
44
|
+
const [permissions, users, roles, stats] = await Promise.all([
|
|
45
|
+
getCrudPermissions(session, "user"),
|
|
46
|
+
getUsersList({
|
|
47
|
+
search: search || undefined,
|
|
48
|
+
roleCode: role && role !== "all" ? role : undefined,
|
|
49
|
+
status,
|
|
50
|
+
}),
|
|
51
|
+
getActiveRoles(db),
|
|
52
|
+
getUserStats(db),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<UsersClientPage
|
|
57
|
+
initialData={users.data}
|
|
58
|
+
config={usersConfig}
|
|
59
|
+
// export tắt cứng — xem ghi chú trong users.config.ts.
|
|
60
|
+
permissions={{ ...permissions, export: false }}
|
|
61
|
+
dictionary={dictionary}
|
|
62
|
+
roles={roles}
|
|
63
|
+
stats={stats}
|
|
64
|
+
menuTree={MENU_TREE}
|
|
65
|
+
actionMeta={getActionMetaMap()}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ErrorView } from "@goerp/core/ui"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Boundary bắt LỖI CỦA LAYOUT: error.tsx chỉ bọc children của segment chứa nó,
|
|
7
|
+
* nên lỗi ném từ (main)/layout.tsx phải leo lên đây mới có người bắt. Boundary
|
|
8
|
+
* này thay CẢ vỏ app → fullScreen.
|
|
9
|
+
*/
|
|
10
|
+
export default function LangError({
|
|
11
|
+
error,
|
|
12
|
+
reset,
|
|
13
|
+
}: {
|
|
14
|
+
error: Error & { digest?: string }
|
|
15
|
+
reset: () => void
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<ErrorView
|
|
19
|
+
error={error}
|
|
20
|
+
reset={reset}
|
|
21
|
+
title="Không tải được dữ liệu"
|
|
22
|
+
description="Hệ thống chưa lấy được dữ liệu cho trang này. Đây là lỗi kỹ thuật tạm thời — quyền truy cập của bạn không thay đổi. Vui lòng thử lại; nếu vẫn lỗi, báo quản trị viên kèm mã lỗi bên dưới."
|
|
23
|
+
fullScreen
|
|
24
|
+
/>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { NotFoundView } from "@goerp/core/ui"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `notFound()` gọi từ trang NGOÀI vỏ app (không sidebar) rơi vào đây —
|
|
5
|
+
* chiếm trọn màn hình. Trang trong vỏ đã có (main)/not-found.tsx giữ lại.
|
|
6
|
+
*/
|
|
7
|
+
export default function LangNotFound() {
|
|
8
|
+
return <NotFoundView fullScreen />
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
|
|
3
|
+
import { apiHandler } from "@/lib/api-handler"
|
|
4
|
+
import { serverError } from "@/lib/errors/server-error"
|
|
5
|
+
import { db } from "@/lib/prisma"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lookup chi nhánh cho picker (UnifiedProfileDialog của core fetch cứng
|
|
9
|
+
* /api/branches). Registry khai `branch` với lookupPolicy "authenticated" —
|
|
10
|
+
* đăng nhập là đọc được, nên KHÔNG gác resource/action (quyền `branch:view`
|
|
11
|
+
* chỉ gate trang quản lý chi nhánh, không gate combo).
|
|
12
|
+
*
|
|
13
|
+
* Fetcher của dialog nhận `{ data: [...] }` (data.items || data.data || mảng).
|
|
14
|
+
*/
|
|
15
|
+
export const GET = apiHandler(async (req) => {
|
|
16
|
+
try {
|
|
17
|
+
const branches = await db.branch.findMany({
|
|
18
|
+
where: { isActive: true },
|
|
19
|
+
select: { id: true, code: true, name: true },
|
|
20
|
+
orderBy: { name: "asc" },
|
|
21
|
+
})
|
|
22
|
+
return NextResponse.json({ data: branches })
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return serverError(error, req, { message: "Lỗi tải danh sách chi nhánh" })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
|
|
3
|
+
import { apiHandler } from "@/lib/api-handler"
|
|
4
|
+
import { serverError } from "@/lib/errors/server-error"
|
|
5
|
+
import { db } from "@/lib/prisma"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lookup phòng ban cho picker (UnifiedProfileDialog của core fetch cứng
|
|
9
|
+
* /api/departments?status=active). Registry khai `department` với lookupPolicy
|
|
10
|
+
* "authenticated" nên chỉ cần đăng nhập, không gác resource/action.
|
|
11
|
+
*
|
|
12
|
+
* Trang quản lý phòng ban vẫn đi qua /api/crud/departments (CRUD engine) —
|
|
13
|
+
* route này CHỈ phục vụ combo.
|
|
14
|
+
*/
|
|
15
|
+
export const GET = apiHandler(async (req) => {
|
|
16
|
+
try {
|
|
17
|
+
const departments = await db.department.findMany({
|
|
18
|
+
where: { status: "active" },
|
|
19
|
+
select: { id: true, code: true, name: true },
|
|
20
|
+
orderBy: { order: "asc" },
|
|
21
|
+
})
|
|
22
|
+
return NextResponse.json({ data: departments })
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return serverError(error, req, { message: "Lỗi tải danh sách phòng ban" })
|
|
25
|
+
}
|
|
26
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
|
|
3
|
+
import { apiHandler } from "@/lib/api-handler"
|
|
4
|
+
import { db } from "@/lib/prisma"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET danh sách chức danh — endpoint đọc gọn cho picker/dataSource. Màn hình
|
|
8
|
+
* quản lý dùng CRUD generic (/crud/job-titles → /api/crud/job-titles).
|
|
9
|
+
*/
|
|
10
|
+
export const GET = apiHandler(
|
|
11
|
+
async (req) => {
|
|
12
|
+
const { searchParams } = new URL(req.url)
|
|
13
|
+
const page = Math.max(Number(searchParams.get("page") || 1), 1)
|
|
14
|
+
const pageSize = Math.min(Number(searchParams.get("pageSize") || 100), 100)
|
|
15
|
+
const search = (searchParams.get("q") || searchParams.get("search"))?.trim()
|
|
16
|
+
const status = searchParams.get("status")?.trim()
|
|
17
|
+
|
|
18
|
+
const where = {
|
|
19
|
+
AND: [
|
|
20
|
+
search
|
|
21
|
+
? {
|
|
22
|
+
OR: [
|
|
23
|
+
{ code: { contains: search, mode: "insensitive" as const } },
|
|
24
|
+
{ name: { contains: search, mode: "insensitive" as const } },
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
: {},
|
|
28
|
+
status && status !== "all" ? { status } : {},
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [total, items] = await Promise.all([
|
|
33
|
+
db.jobTitle.count({ where }),
|
|
34
|
+
db.jobTitle.findMany({
|
|
35
|
+
where,
|
|
36
|
+
orderBy: [{ level: "desc" }, { name: "asc" }],
|
|
37
|
+
skip: (page - 1) * pageSize,
|
|
38
|
+
take: pageSize,
|
|
39
|
+
}),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
return NextResponse.json({ total, page, pageSize, items })
|
|
43
|
+
},
|
|
44
|
+
// Combo tra cứu — registry khai lookupPolicy "authenticated" cho job-title:
|
|
45
|
+
// ai đăng nhập cũng đổ được picker, không đòi quyền view của trang quản lý.
|
|
46
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
import { checkPermission } from "@goerp/core/auth"
|
|
3
|
+
|
|
4
|
+
import { apiHandler } from "@/lib/api-handler"
|
|
5
|
+
import { getAuth } from "@/lib/better-auth"
|
|
6
|
+
import { serverError } from "@/lib/errors/server-error"
|
|
7
|
+
import { db } from "@/lib/prisma"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DELETE /api/security/sessions/[id] — thu hồi 1 phiên đăng nhập.
|
|
11
|
+
* - Phiên của mình: được thu hồi (trừ phiên HIỆN TẠI — dùng nút Đăng xuất).
|
|
12
|
+
* - Phiên user khác: cần quyền user:update (thao tác quản trị).
|
|
13
|
+
* Xóa row `sessions` = phiên chết ngay (session DB-backed).
|
|
14
|
+
*/
|
|
15
|
+
export const DELETE = apiHandler(async (req, { session, params }) => {
|
|
16
|
+
try {
|
|
17
|
+
const { id } = await params
|
|
18
|
+
const row = await db.session.findUnique({
|
|
19
|
+
where: { id },
|
|
20
|
+
select: { id: true, userId: true, token: true },
|
|
21
|
+
})
|
|
22
|
+
if (!row) {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Phiên không tồn tại" },
|
|
25
|
+
{ status: 404 },
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isMine = row.userId === session.user.id
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
if (!isMine && !checkPermission(session as Parameters<typeof checkPermission>[0], "user", "update")) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ error: "Bạn không có quyền thu hồi phiên của người dùng khác" },
|
|
34
|
+
{ status: 403 },
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Không tự thu hồi phiên đang dùng (tránh tự bắn vào chân — dùng Đăng xuất)
|
|
39
|
+
const current = await getAuth().api.getSession({ headers: req.headers })
|
|
40
|
+
if (current?.session?.token && row.token === current.session.token) {
|
|
41
|
+
return NextResponse.json(
|
|
42
|
+
{ error: "Không thể thu hồi phiên hiện tại — hãy dùng Đăng xuất" },
|
|
43
|
+
{ status: 400 },
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await db.session.delete({ where: { id } })
|
|
48
|
+
return NextResponse.json({ ok: true })
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return serverError(error, req, { message: "Không thu hồi được phiên" })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
|
|
3
|
+
import { apiHandler } from "@/lib/api-handler"
|
|
4
|
+
import { getAuth } from "@/lib/better-auth"
|
|
5
|
+
import { serverError } from "@/lib/errors/server-error"
|
|
6
|
+
import { db } from "@/lib/prisma"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/security/sessions/revoke-user — QUẢN TRỊ: đăng xuất MỌI thiết bị
|
|
10
|
+
* của 1 người dùng (xóa toàn bộ row `sessions` của user đó → chết ngay).
|
|
11
|
+
* - Cần user:update (thao tác quản trị).
|
|
12
|
+
* - Nếu tự thu hồi chính mình: giữ lại phiên HIỆN TẠI (không tự bắn văng).
|
|
13
|
+
* Dùng khi: nghi ngờ lộ tài khoản, thu máy, cho nghỉ việc...
|
|
14
|
+
*/
|
|
15
|
+
export const POST = apiHandler(
|
|
16
|
+
async (req, { session }) => {
|
|
17
|
+
try {
|
|
18
|
+
const body = await req.json().catch(() => null)
|
|
19
|
+
const userId = typeof body?.userId === "string" ? body.userId : ""
|
|
20
|
+
if (!userId) {
|
|
21
|
+
return NextResponse.json({ error: "Thiếu userId" }, { status: 400 })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Tự thu hồi chính mình → chừa phiên hiện tại
|
|
25
|
+
let excludeToken: string | undefined
|
|
26
|
+
if (userId === session.user.id) {
|
|
27
|
+
const current = await getAuth().api.getSession({ headers: req.headers })
|
|
28
|
+
excludeToken = current?.session?.token ?? undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const r = await db.session.deleteMany({
|
|
32
|
+
where: {
|
|
33
|
+
userId,
|
|
34
|
+
...(excludeToken ? { token: { not: excludeToken } } : {}),
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return NextResponse.json({ revoked: r.count })
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return serverError(error, req, { message: "Không thu hồi được phiên" })
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{ resource: "user", action: "update" },
|
|
44
|
+
)
|