@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +1 -1
  4. package/src/auth/proxy-gate.ts +21 -2
  5. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  6. package/src/cron/cron-schedule.ts +128 -0
  7. package/src/cron/simple-cron-job.ts +83 -69
  8. package/src/crud/components/crud-table.tsx +13 -1
  9. package/src/crud/lib/translate-config.ts +16 -2
  10. package/src/guardrails/index.ts +1 -0
  11. package/src/guardrails/rules/auth.ts +54 -1
  12. package/src/guardrails/types.ts +17 -8
  13. package/src/providers/brand-theme.ts +20 -0
  14. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  15. package/src/security/index.ts +2 -0
  16. package/src/security/pages/sessions-page.tsx +516 -0
  17. package/src/ui/auth/sign-in-form.tsx +64 -9
  18. package/src/ui/errors/error-view.tsx +164 -0
  19. package/src/ui/errors/index.ts +2 -0
  20. package/src/ui/errors/not-found-view.tsx +44 -0
  21. package/src/ui/index.tsx +1 -0
  22. package/src/ui/layout/logo.tsx +54 -19
  23. package/src/user/pages/users-client-page.tsx +12 -7
  24. package/templates/starter-app/.env.example +4 -0
  25. package/templates/starter-app/README.md +6 -2
  26. package/templates/starter-app/husky/pre-commit +10 -0
  27. package/templates/starter-app/package.json +27 -2
  28. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  29. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  30. package/templates/starter-app/prisma/seed.ts +7 -5
  31. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  32. package/templates/starter-app/src/__tests__/architecture.test.ts +8 -0
  33. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  34. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +69 -0
  35. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
  36. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
  37. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
  38. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  39. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  40. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
  41. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  42. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  43. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +60 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -0
  48. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  49. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  50. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  51. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  52. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  53. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  54. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  55. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  56. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  57. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  58. package/templates/starter-app/src/app/api/system-categories/route.ts +154 -0
  59. package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
  60. package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
  61. package/templates/starter-app/src/app/api/users/route.ts +139 -0
  62. package/templates/starter-app/src/app/global-error.tsx +135 -0
  63. package/templates/starter-app/src/app/icon.svg +6 -0
  64. package/templates/starter-app/src/app/manifest.ts +26 -0
  65. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  66. package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
  67. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
  68. package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
  69. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  70. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  71. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  72. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  73. package/templates/starter-app/src/configs/tenant.ts +2 -1
  74. package/templates/starter-app/src/data/dictionary.ts +76 -4
  75. package/templates/starter-app/src/data/navigations.ts +49 -0
  76. package/templates/starter-app/src/instrumentation.ts +5 -16
  77. package/templates/starter-app/src/lib/action-guard.ts +26 -0
  78. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  79. package/templates/starter-app/src/lib/crud/index.ts +2 -0
  80. package/templates/starter-app/src/lib/prisma.ts +3 -2
  81. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  82. package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
  83. package/templates/starter-app/src/proxy.ts +8 -3
  84. package/templates/starter-app/src/server/services/company-service.ts +47 -0
  85. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  86. 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,9 @@
1
+ import { notFound } from "next/navigation"
2
+
3
+ /**
4
+ * URL gõ sai bất kỳ dưới /[lang] rơi vào catch-all này → kích hoạt
5
+ * [lang]/not-found.tsx thay vì trang trắng mặc định của Next.
6
+ */
7
+ export default function CatchAllNotFound() {
8
+ notFound()
9
+ }
@@ -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
+ )