@goplusvn/core 0.1.69 → 0.1.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -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/print/print-styles.tsx +4 -1
- 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,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
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
* GET /api/security/sessions — QUẢN TRỊ phiên đăng nhập toàn hệ thống
|
|
10
|
+
* (bảng `sessions` của Better Auth). Gate user:view. Search: tên/email user,
|
|
11
|
+
* IP, user-agent. Phân trang chuẩn {items,total,page,pageSize}. KHÔNG trả
|
|
12
|
+
* token (nhạy cảm) — thu hồi dùng id.
|
|
13
|
+
*/
|
|
14
|
+
export const GET = apiHandler(
|
|
15
|
+
async (req, { session }) => {
|
|
16
|
+
try {
|
|
17
|
+
const sp = req.nextUrl.searchParams
|
|
18
|
+
const search = sp.get("search")?.trim() || ""
|
|
19
|
+
const page = Math.max(Number(sp.get("page") || 1), 1)
|
|
20
|
+
const pageSize = Math.min(
|
|
21
|
+
Math.max(Number(sp.get("pageSize") || 20), 1),
|
|
22
|
+
100,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// Token của phiên hiện tại (đánh dấu "thiết bị này" — so sánh server-side,
|
|
26
|
+
// không lộ token ra client)
|
|
27
|
+
const current = await getAuth().api.getSession({ headers: req.headers })
|
|
28
|
+
const currentToken = current?.session?.token ?? null
|
|
29
|
+
|
|
30
|
+
const where = {
|
|
31
|
+
expiresAt: { gt: new Date() }, // chỉ phiên còn hiệu lực
|
|
32
|
+
...(search
|
|
33
|
+
? {
|
|
34
|
+
OR: [
|
|
35
|
+
{
|
|
36
|
+
ipAddress: { contains: search, mode: "insensitive" as const },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
userAgent: { contains: search, mode: "insensitive" as const },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
user: {
|
|
43
|
+
OR: [
|
|
44
|
+
{
|
|
45
|
+
name: {
|
|
46
|
+
contains: search,
|
|
47
|
+
mode: "insensitive" as const,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
email: {
|
|
52
|
+
contains: search,
|
|
53
|
+
mode: "insensitive" as const,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
: {}),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [total, rows] = await Promise.all([
|
|
65
|
+
db.session.count({ where }),
|
|
66
|
+
db.session.findMany({
|
|
67
|
+
where,
|
|
68
|
+
include: { user: { select: { name: true, email: true } } },
|
|
69
|
+
orderBy: { updatedAt: "desc" },
|
|
70
|
+
skip: (page - 1) * pageSize,
|
|
71
|
+
take: pageSize,
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
return NextResponse.json({
|
|
76
|
+
items: rows.map((r) => ({
|
|
77
|
+
id: r.id,
|
|
78
|
+
userId: r.userId,
|
|
79
|
+
userName: r.user?.name ?? null,
|
|
80
|
+
userEmail: r.user?.email ?? null,
|
|
81
|
+
ipAddress: r.ipAddress,
|
|
82
|
+
userAgent: r.userAgent,
|
|
83
|
+
createdAt: r.createdAt,
|
|
84
|
+
updatedAt: r.updatedAt,
|
|
85
|
+
expiresAt: r.expiresAt,
|
|
86
|
+
isCurrent: currentToken !== null && r.token === currentToken,
|
|
87
|
+
isMine: r.userId === session.user.id,
|
|
88
|
+
impersonatedBy: null,
|
|
89
|
+
})),
|
|
90
|
+
total,
|
|
91
|
+
page,
|
|
92
|
+
pageSize,
|
|
93
|
+
})
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return serverError(error, req, {
|
|
96
|
+
message: "Không tải được danh sách phiên",
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{ resource: "user", action: "view" },
|
|
101
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NextResponse } from "next/server"
|
|
2
|
+
|
|
3
|
+
import { apiHandler } from "@/lib/api-handler"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* STUB — template không có model Supplier. UnifiedProfileDialog của core
|
|
7
|
+
* fetch cứng /api/suppliers?pageSize=100 (tab "Nhà cung cấp" khi userType =
|
|
8
|
+
* supplier); trả rỗng để dialog không vỡ. App nào có nhà cung cấp thì thay
|
|
9
|
+
* bằng query thật (shape: { data: [{ id, name }] }).
|
|
10
|
+
*/
|
|
11
|
+
export const GET = apiHandler(async () => {
|
|
12
|
+
return NextResponse.json({ data: [] })
|
|
13
|
+
})
|