@thinhnguyencth1204/nextcli 0.3.0 → 0.4.0
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/README.md +6 -2
- package/dist/cli.js +210 -75
- package/package.json +2 -1
- package/templates/next-base/PROJECT_STRUCTURE.md +88 -0
- package/templates/next-base/SETUP.md +86 -0
- package/templates/next-base/bun.lock +1443 -0
- package/templates/next-base/messages/vi/auth.json +18 -4
- package/templates/next-base/next-env.d.ts +3 -1
- package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +104 -0
- package/templates/next-base/prisma/migrations/migration_lock.toml +3 -0
- package/templates/next-base/prisma/schema.prisma +23 -9
- package/templates/next-base/public/logo.svg +4 -0
- package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
- package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
- package/templates/next-base/src/app/(auth)/layout.tsx +3 -3
- package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
- package/templates/next-base/src/app/(dashboard)/layout.tsx +13 -1
- package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +15 -5
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +17 -19
- package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
- package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
- package/templates/next-base/src/app/globals.css +4 -0
- package/templates/next-base/src/app/layout.tsx +7 -3
- package/templates/next-base/src/components/branding/logo.tsx +27 -0
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +3 -4
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +2 -1
- package/templates/next-base/src/config/branding.ts +14 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +12 -7
- package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +18 -13
- package/templates/next-base/src/features/auth/validations.ts +7 -1
- package/templates/next-base/src/features/users/services.ts +132 -0
- package/templates/next-base/src/features/users/validations.ts +21 -0
- package/templates/next-base/src/instrumentation.ts +14 -0
- package/templates/next-base/src/lib/auth-client.ts +2 -2
- package/templates/next-base/src/lib/auth.ts +2 -2
- package/templates/next-base/src/lib/bootstrap.ts +96 -0
- package/templates/next-base/src/lib/constants.ts +7 -0
- package/templates/next-base/src/lib/rbac.ts +62 -0
- package/templates/next-base/tsconfig.json +29 -7
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"signInPage": {
|
|
3
3
|
"title": "Đăng nhập",
|
|
4
|
-
"description": "Dùng
|
|
4
|
+
"description": "Dùng tên đăng nhập và mật khẩu để truy cập hệ thống."
|
|
5
5
|
},
|
|
6
6
|
"signInForm": {
|
|
7
|
-
"
|
|
7
|
+
"username": "Tên đăng nhập",
|
|
8
8
|
"password": "Mật khẩu",
|
|
9
|
-
"
|
|
9
|
+
"usernamePlaceholder": "admin",
|
|
10
10
|
"passwordPlaceholder": "********",
|
|
11
11
|
"submit": "Đăng nhập",
|
|
12
12
|
"submitting": "Đang đăng nhập...",
|
|
13
|
-
"invalidInput": "Vui lòng nhập
|
|
13
|
+
"invalidInput": "Vui lòng nhập tên đăng nhập và mật khẩu hợp lệ.",
|
|
14
14
|
"missingAccessToken": "Không tìm thấy access token.",
|
|
15
15
|
"success": "Đăng nhập thành công.",
|
|
16
16
|
"failed": "Đăng nhập thất bại."
|
|
17
17
|
},
|
|
18
|
+
"changePasswordPage": {
|
|
19
|
+
"title": "Đổi mật khẩu",
|
|
20
|
+
"description": "Bạn cần đổi mật khẩu trước khi tiếp tục sử dụng hệ thống."
|
|
21
|
+
},
|
|
22
|
+
"changePasswordForm": {
|
|
23
|
+
"currentPassword": "Mật khẩu hiện tại",
|
|
24
|
+
"newPassword": "Mật khẩu mới",
|
|
25
|
+
"submit": "Cập nhật mật khẩu",
|
|
26
|
+
"submitting": "Đang cập nhật...",
|
|
27
|
+
"invalidInput": "Vui lòng nhập mật khẩu hợp lệ (tối thiểu 8 ký tự).",
|
|
28
|
+
"success": "Đổi mật khẩu thành công.",
|
|
29
|
+
"failed": "Không thể đổi mật khẩu."
|
|
30
|
+
},
|
|
18
31
|
"account": {
|
|
19
32
|
"title": "Tài khoản",
|
|
20
33
|
"loading": "Đang tải thông tin tài khoản...",
|
|
21
34
|
"noSession": "Chưa có phiên đăng nhập hoạt động.",
|
|
22
35
|
"userId": "Mã người dùng",
|
|
36
|
+
"username": "Tên đăng nhập",
|
|
23
37
|
"email": "Email",
|
|
24
38
|
"name": "Tên",
|
|
25
39
|
"na": "N/A",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
3
4
|
|
|
4
|
-
// This file
|
|
5
|
+
// NOTE: This file should not be edited
|
|
6
|
+
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
CREATE TABLE "Role" (
|
|
3
|
+
"id" TEXT NOT NULL,
|
|
4
|
+
"name" TEXT NOT NULL,
|
|
5
|
+
"level" INTEGER NOT NULL,
|
|
6
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
7
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
8
|
+
|
|
9
|
+
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
-- CreateTable
|
|
13
|
+
CREATE TABLE "User" (
|
|
14
|
+
"id" TEXT NOT NULL,
|
|
15
|
+
"email" TEXT,
|
|
16
|
+
"username" TEXT NOT NULL,
|
|
17
|
+
"displayUsername" TEXT,
|
|
18
|
+
"name" TEXT,
|
|
19
|
+
"image" TEXT,
|
|
20
|
+
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
|
|
21
|
+
"requirePasswordChange" BOOLEAN NOT NULL DEFAULT false,
|
|
22
|
+
"roleId" TEXT,
|
|
23
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
25
|
+
|
|
26
|
+
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- CreateTable
|
|
30
|
+
CREATE TABLE "Example" (
|
|
31
|
+
"id" TEXT NOT NULL,
|
|
32
|
+
"name" TEXT NOT NULL,
|
|
33
|
+
"description" TEXT,
|
|
34
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
35
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
36
|
+
|
|
37
|
+
CONSTRAINT "Example_pkey" PRIMARY KEY ("id")
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- CreateTable
|
|
41
|
+
CREATE TABLE "Session" (
|
|
42
|
+
"id" TEXT NOT NULL,
|
|
43
|
+
"token" TEXT NOT NULL,
|
|
44
|
+
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
45
|
+
"ipAddress" TEXT,
|
|
46
|
+
"userAgent" TEXT,
|
|
47
|
+
"userId" TEXT NOT NULL,
|
|
48
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
49
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
50
|
+
|
|
51
|
+
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
-- CreateTable
|
|
55
|
+
CREATE TABLE "Account" (
|
|
56
|
+
"id" TEXT NOT NULL,
|
|
57
|
+
"accountId" TEXT NOT NULL,
|
|
58
|
+
"providerId" TEXT NOT NULL,
|
|
59
|
+
"userId" TEXT NOT NULL,
|
|
60
|
+
"accessToken" TEXT,
|
|
61
|
+
"refreshToken" TEXT,
|
|
62
|
+
"idToken" TEXT,
|
|
63
|
+
"accessTokenExpiresAt" TIMESTAMP(3),
|
|
64
|
+
"refreshTokenExpiresAt" TIMESTAMP(3),
|
|
65
|
+
"scope" TEXT,
|
|
66
|
+
"password" TEXT,
|
|
67
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
68
|
+
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
69
|
+
|
|
70
|
+
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- CreateTable
|
|
74
|
+
CREATE TABLE "Verification" (
|
|
75
|
+
"id" TEXT NOT NULL,
|
|
76
|
+
"identifier" TEXT NOT NULL,
|
|
77
|
+
"value" TEXT NOT NULL,
|
|
78
|
+
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
79
|
+
"createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
|
|
80
|
+
"updatedAt" TIMESTAMP(3),
|
|
81
|
+
|
|
82
|
+
CONSTRAINT "Verification_pkey" PRIMARY KEY ("id")
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- CreateIndex
|
|
86
|
+
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
|
87
|
+
|
|
88
|
+
-- CreateIndex
|
|
89
|
+
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
|
90
|
+
|
|
91
|
+
-- CreateIndex
|
|
92
|
+
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
|
93
|
+
|
|
94
|
+
-- CreateIndex
|
|
95
|
+
CREATE UNIQUE INDEX "Account_providerId_accountId_key" ON "Account"("providerId", "accountId");
|
|
96
|
+
|
|
97
|
+
-- AddForeignKey
|
|
98
|
+
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
99
|
+
|
|
100
|
+
-- AddForeignKey
|
|
101
|
+
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
102
|
+
|
|
103
|
+
-- AddForeignKey
|
|
104
|
+
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@@ -6,16 +6,30 @@ datasource db {
|
|
|
6
6
|
provider = "postgresql"
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
model Role {
|
|
10
|
+
id String @id @default(cuid())
|
|
11
|
+
name String @unique
|
|
12
|
+
level Int
|
|
13
|
+
createdAt DateTime @default(now())
|
|
14
|
+
updatedAt DateTime @updatedAt
|
|
15
|
+
users User[]
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
model User {
|
|
10
|
-
id
|
|
11
|
-
email
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
id String @id @default(cuid())
|
|
20
|
+
email String?
|
|
21
|
+
username String @unique
|
|
22
|
+
displayUsername String?
|
|
23
|
+
name String?
|
|
24
|
+
image String?
|
|
25
|
+
emailVerified Boolean @default(false)
|
|
26
|
+
requirePasswordChange Boolean @default(false)
|
|
27
|
+
roleId String?
|
|
28
|
+
role Role? @relation(fields: [roleId], references: [id], onDelete: SetNull)
|
|
29
|
+
createdAt DateTime @default(now())
|
|
30
|
+
updatedAt DateTime @updatedAt
|
|
31
|
+
sessions Session[]
|
|
32
|
+
accounts Account[]
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
// Starter demo table for template onboarding only.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
|
2
|
+
<rect width="64" height="64" rx="12" fill="oklch(0.205 0 0)"/>
|
|
3
|
+
<path d="M18 44V20h10.5c5.2 0 8.5 2.8 8.5 7.2 0 3.1-1.7 5.4-4.5 6.5L38 44h-6.2l-4.8-9.2H24.2V44H18zm6.2-14.8h3.8c2.4 0 3.8-1.2 3.8-3.1s-1.4-3.1-3.8-3.1h-3.8v6.2z" fill="oklch(0.985 0 0)"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { getSessionUser } from "@/lib/rbac";
|
|
4
|
+
|
|
5
|
+
export default async function ChangePasswordLayout({
|
|
6
|
+
children,
|
|
7
|
+
}: {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}) {
|
|
10
|
+
const user = await getSessionUser();
|
|
11
|
+
|
|
12
|
+
if (!user) {
|
|
13
|
+
redirect("/sign-in");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!user.requirePasswordChange) {
|
|
17
|
+
redirect("/dashboard");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return <>{children}</>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useTranslations } from "next-intl";
|
|
2
|
+
import { ChangePasswordForm } from "@/features/auth/components/change-password-form";
|
|
3
|
+
|
|
4
|
+
export default function ChangePasswordPage() {
|
|
5
|
+
const t = useTranslations("auth.changePasswordPage");
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<main className="space-y-3">
|
|
9
|
+
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
|
10
|
+
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
|
11
|
+
<ChangePasswordForm />
|
|
12
|
+
</main>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
|
-
export default function
|
|
3
|
+
export default function AuthRouteLayout({ children }: { children: ReactNode }) {
|
|
4
4
|
return (
|
|
5
|
-
<
|
|
5
|
+
<div className="flex min-h-screen items-center justify-center p-4">
|
|
6
6
|
<div className="w-full max-w-md">{children}</div>
|
|
7
|
-
</
|
|
7
|
+
</div>
|
|
8
8
|
);
|
|
9
9
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { getSessionUser } from "@/lib/rbac";
|
|
4
|
+
|
|
5
|
+
export default async function SignInLayout({ children }: { children: ReactNode }) {
|
|
6
|
+
const user = await getSessionUser();
|
|
7
|
+
|
|
8
|
+
if (user?.requirePasswordChange) {
|
|
9
|
+
redirect("/change-password");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (user) {
|
|
13
|
+
redirect("/dashboard");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return children;
|
|
17
|
+
}
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
2
3
|
import { DashboardLayout } from "@/components/layout/private/dashboard-layout";
|
|
4
|
+
import { getSessionUser } from "@/lib/rbac";
|
|
3
5
|
|
|
4
|
-
export default function DashboardRouteLayout({
|
|
6
|
+
export default async function DashboardRouteLayout({
|
|
5
7
|
children,
|
|
6
8
|
}: {
|
|
7
9
|
children: ReactNode;
|
|
8
10
|
}) {
|
|
11
|
+
const user = await getSessionUser();
|
|
12
|
+
|
|
13
|
+
if (!user) {
|
|
14
|
+
redirect("/sign-in");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (user.requirePasswordChange) {
|
|
18
|
+
redirect("/change-password");
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
return <DashboardLayout>{children}</DashboardLayout>;
|
|
10
22
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { changePasswordSchema } from "@/features/auth/validations";
|
|
2
|
+
import { fail, ok } from "@/lib/api-response";
|
|
3
|
+
import { auth } from "@/lib/auth";
|
|
4
|
+
import { getSessionUser } from "@/lib/rbac";
|
|
5
|
+
import prisma from "@/lib/prisma";
|
|
6
|
+
|
|
7
|
+
const authBaseUrl =
|
|
8
|
+
process.env.BETTER_AUTH_URL ??
|
|
9
|
+
process.env.NEXT_PUBLIC_APP_URL ??
|
|
10
|
+
"http://localhost:3000";
|
|
11
|
+
|
|
12
|
+
export async function POST(request: Request) {
|
|
13
|
+
const actor = await getSessionUser(request.headers);
|
|
14
|
+
if (!actor) {
|
|
15
|
+
return fail("UNAUTHORIZED", "Unauthorized", { status: 401 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const payload = await request.json().catch(() => null);
|
|
19
|
+
const parsed = changePasswordSchema.safeParse(payload);
|
|
20
|
+
|
|
21
|
+
if (!parsed.success) {
|
|
22
|
+
return fail("VALIDATION_ERROR", "Invalid password payload.", {
|
|
23
|
+
status: 400,
|
|
24
|
+
details: parsed.error.flatten(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const verifyResponse = await fetch(`${authBaseUrl}/api/auth/sign-in/username`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
username: actor.username,
|
|
33
|
+
password: parsed.data.currentPassword,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!verifyResponse.ok) {
|
|
38
|
+
return fail("UNAUTHORIZED", "Current password is incorrect.", { status: 401 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await auth.api.changePassword({
|
|
42
|
+
body: {
|
|
43
|
+
currentPassword: parsed.data.currentPassword,
|
|
44
|
+
newPassword: parsed.data.newPassword,
|
|
45
|
+
},
|
|
46
|
+
headers: request.headers,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await prisma.user.update({
|
|
50
|
+
where: { id: actor.id },
|
|
51
|
+
data: { requirePasswordChange: false },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return ok({ updated: true });
|
|
55
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getRefreshCookieName, refreshCookieOptions } from "@/lib/auth-cookies";
|
|
2
2
|
import { fail, ok } from "@/lib/api-response";
|
|
3
|
+
import prisma from "@/lib/prisma";
|
|
3
4
|
|
|
4
5
|
const authBaseUrl =
|
|
5
6
|
process.env.BETTER_AUTH_URL ??
|
|
@@ -8,21 +9,23 @@ const authBaseUrl =
|
|
|
8
9
|
|
|
9
10
|
export async function POST(request: Request) {
|
|
10
11
|
const payload = (await request.json().catch(() => ({}))) as {
|
|
11
|
-
|
|
12
|
+
username?: string;
|
|
12
13
|
password?: string;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
if (!payload.
|
|
16
|
-
return fail("BAD_REQUEST", "
|
|
16
|
+
if (!payload.username || !payload.password) {
|
|
17
|
+
return fail("BAD_REQUEST", "Username and password are required.", {
|
|
18
|
+
status: 400,
|
|
19
|
+
});
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
const signInResponse = await fetch(`${authBaseUrl}/api/auth/sign-in/
|
|
22
|
+
const signInResponse = await fetch(`${authBaseUrl}/api/auth/sign-in/username`, {
|
|
20
23
|
method: "POST",
|
|
21
24
|
headers: {
|
|
22
25
|
"Content-Type": "application/json",
|
|
23
26
|
},
|
|
24
27
|
body: JSON.stringify({
|
|
25
|
-
|
|
28
|
+
username: payload.username,
|
|
26
29
|
password: payload.password,
|
|
27
30
|
}),
|
|
28
31
|
});
|
|
@@ -47,8 +50,15 @@ export async function POST(request: Request) {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const tokenPayload = await tokenResponse.json();
|
|
53
|
+
|
|
54
|
+
const dbUser = await prisma.user.findUnique({
|
|
55
|
+
where: { username: payload.username },
|
|
56
|
+
select: { requirePasswordChange: true },
|
|
57
|
+
});
|
|
58
|
+
|
|
50
59
|
const response = ok({
|
|
51
60
|
accessToken: tokenPayload.token,
|
|
61
|
+
requirePasswordChange: dbUser?.requirePasswordChange ?? false,
|
|
52
62
|
});
|
|
53
63
|
|
|
54
64
|
if (cookieHeader) {
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
import { fail, ok } from "@/lib/api-response";
|
|
2
|
-
|
|
3
|
-
const authBaseUrl =
|
|
4
|
-
process.env.BETTER_AUTH_URL ??
|
|
5
|
-
process.env.NEXT_PUBLIC_APP_URL ??
|
|
6
|
-
"http://localhost:3000";
|
|
2
|
+
import { getSessionUser } from "@/lib/rbac";
|
|
7
3
|
|
|
8
4
|
export async function GET(request: Request) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
const sessionResponse = await fetch(`${authBaseUrl}/api/auth/get-session`, {
|
|
12
|
-
method: "GET",
|
|
13
|
-
headers: {
|
|
14
|
-
cookie: incomingCookie,
|
|
15
|
-
},
|
|
16
|
-
});
|
|
5
|
+
const user = await getSessionUser(request.headers);
|
|
17
6
|
|
|
18
|
-
if (!
|
|
19
|
-
return fail("UNAUTHORIZED", "Unauthorized", {
|
|
20
|
-
status: sessionResponse.status,
|
|
21
|
-
});
|
|
7
|
+
if (!user) {
|
|
8
|
+
return fail("UNAUTHORIZED", "Unauthorized", { status: 401 });
|
|
22
9
|
}
|
|
23
10
|
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
return ok({
|
|
12
|
+
user: {
|
|
13
|
+
id: user.id,
|
|
14
|
+
username: user.username,
|
|
15
|
+
displayUsername: user.displayUsername,
|
|
16
|
+
name: user.name,
|
|
17
|
+
email: user.email,
|
|
18
|
+
requirePasswordChange: user.requirePasswordChange,
|
|
19
|
+
role: user.role
|
|
20
|
+
? { id: user.role.id, name: user.role.name, level: user.role.level }
|
|
21
|
+
: null,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
26
24
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { updateUserSchema } from "@/features/users/validations";
|
|
2
|
+
import {
|
|
3
|
+
deleteUserRecord,
|
|
4
|
+
getUserByIdForActor,
|
|
5
|
+
updateUserRecord,
|
|
6
|
+
} from "@/features/users/services";
|
|
7
|
+
import { fail, ok } from "@/lib/api-response";
|
|
8
|
+
import {
|
|
9
|
+
canActOnUser,
|
|
10
|
+
canAssignRole,
|
|
11
|
+
getSessionUser,
|
|
12
|
+
isSuperAdmin,
|
|
13
|
+
} from "@/lib/rbac";
|
|
14
|
+
import prisma from "@/lib/prisma";
|
|
15
|
+
|
|
16
|
+
type RouteContext = {
|
|
17
|
+
params: Promise<{ id: string }>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function GET(request: Request, context: RouteContext) {
|
|
21
|
+
const actor = await getSessionUser(request.headers);
|
|
22
|
+
if (!actor?.role) {
|
|
23
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { id } = await context.params;
|
|
27
|
+
const user = await getUserByIdForActor(id, actor.role.level);
|
|
28
|
+
|
|
29
|
+
if (!user) {
|
|
30
|
+
return fail("NOT_FOUND", "User not found.", { status: 404 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return ok(user);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function PATCH(request: Request, context: RouteContext) {
|
|
37
|
+
const actor = await getSessionUser(request.headers);
|
|
38
|
+
if (!actor?.role) {
|
|
39
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { id } = await context.params;
|
|
43
|
+
const target = await prisma.user.findUnique({
|
|
44
|
+
where: { id },
|
|
45
|
+
include: { role: true },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!target || !canActOnUser(actor, target)) {
|
|
49
|
+
return fail("FORBIDDEN", "Cannot modify this user.", { status: 403 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const payload = await request.json().catch(() => null);
|
|
53
|
+
const parsed = updateUserSchema.safeParse(payload);
|
|
54
|
+
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
return fail("VALIDATION_ERROR", "Invalid user payload.", {
|
|
57
|
+
status: 400,
|
|
58
|
+
details: parsed.error.flatten(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (parsed.data.roleId) {
|
|
63
|
+
const nextRole = await prisma.role.findUnique({
|
|
64
|
+
where: { id: parsed.data.roleId },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (!nextRole || !canAssignRole(actor, nextRole)) {
|
|
68
|
+
return fail("FORBIDDEN", "Cannot assign the requested role.", {
|
|
69
|
+
status: 403,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const updated = await updateUserRecord(id, parsed.data);
|
|
75
|
+
if (!updated) {
|
|
76
|
+
return fail("NOT_FOUND", "User not found.", { status: 404 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return ok(updated);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function DELETE(request: Request, context: RouteContext) {
|
|
83
|
+
const actor = await getSessionUser(request.headers);
|
|
84
|
+
if (!actor?.role) {
|
|
85
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { id } = await context.params;
|
|
89
|
+
const target = await prisma.user.findUnique({
|
|
90
|
+
where: { id },
|
|
91
|
+
include: { role: true },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!target) {
|
|
95
|
+
return fail("NOT_FOUND", "User not found.", { status: 404 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isSuperAdmin(target) || !canActOnUser(actor, target)) {
|
|
99
|
+
return fail("FORBIDDEN", "Cannot delete this user.", { status: 403 });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await deleteUserRecord(id);
|
|
103
|
+
return ok({ deleted: true });
|
|
104
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createUserSchema } from "@/features/users/validations";
|
|
2
|
+
import {
|
|
3
|
+
createUserRecord,
|
|
4
|
+
listUsersForActor,
|
|
5
|
+
} from "@/features/users/services";
|
|
6
|
+
import { fail, ok } from "@/lib/api-response";
|
|
7
|
+
import { canAssignRole, getSessionUser } from "@/lib/rbac";
|
|
8
|
+
import prisma from "@/lib/prisma";
|
|
9
|
+
|
|
10
|
+
export async function GET(request: Request) {
|
|
11
|
+
const actor = await getSessionUser(request.headers);
|
|
12
|
+
if (!actor?.role) {
|
|
13
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const users = await listUsersForActor(actor.role.level);
|
|
17
|
+
return ok({ items: users });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function POST(request: Request) {
|
|
21
|
+
const actor = await getSessionUser(request.headers);
|
|
22
|
+
if (!actor?.role) {
|
|
23
|
+
return fail("FORBIDDEN", "Insufficient permissions.", { status: 403 });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const payload = await request.json().catch(() => null);
|
|
27
|
+
const parsed = createUserSchema.safeParse(payload);
|
|
28
|
+
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
return fail("VALIDATION_ERROR", "Invalid user payload.", {
|
|
31
|
+
status: 400,
|
|
32
|
+
details: parsed.error.flatten(),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const targetRole = await prisma.role.findUnique({
|
|
37
|
+
where: { id: parsed.data.roleId },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!targetRole || !canAssignRole(actor, targetRole)) {
|
|
41
|
+
return fail("FORBIDDEN", "Cannot assign the requested role.", { status: 403 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const existing = await prisma.user.findUnique({
|
|
45
|
+
where: { username: parsed.data.username },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (existing) {
|
|
49
|
+
return fail("CONFLICT", "Username already exists.", { status: 409 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const created = await createUserRecord(parsed.data);
|
|
54
|
+
return ok(created, { status: 201 });
|
|
55
|
+
} catch {
|
|
56
|
+
return fail("INTERNAL_ERROR", "Failed to create user.", { status: 500 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "tw-animate-css";
|
|
3
3
|
|
|
4
|
+
/* nextcli:theme:start — edit brand colors below */
|
|
4
5
|
:root {
|
|
5
6
|
--background: oklch(1 0 0);
|
|
6
7
|
--foreground: oklch(0.145 0 0);
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
--sidebar-ring: oklch(0.708 0 0);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/* nextcli:theme-dark:start */
|
|
35
37
|
.dark {
|
|
36
38
|
--background: oklch(0.145 0 0);
|
|
37
39
|
--foreground: oklch(0.985 0 0);
|
|
@@ -61,6 +63,8 @@
|
|
|
61
63
|
--sidebar-border: oklch(1 0 0 / 10%);
|
|
62
64
|
--sidebar-ring: oklch(0.556 0 0);
|
|
63
65
|
}
|
|
66
|
+
/* nextcli:theme-dark:end */
|
|
67
|
+
/* nextcli:theme:end */
|
|
64
68
|
|
|
65
69
|
@theme inline {
|
|
66
70
|
--color-background: var(--background);
|
|
@@ -6,13 +6,17 @@ import { Toaster } from "sonner";
|
|
|
6
6
|
import type { ReactNode } from "react";
|
|
7
7
|
import { QueryProvider } from "@/components/providers/query-provider";
|
|
8
8
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
|
9
|
+
import { branding } from "@/config/branding";
|
|
9
10
|
import "@/app/globals.css";
|
|
10
11
|
|
|
11
|
-
const beVietnamPro = Be_Vietnam_Pro({
|
|
12
|
+
const beVietnamPro = Be_Vietnam_Pro({
|
|
13
|
+
subsets: ["latin", "vietnamese"],
|
|
14
|
+
weight: ["400", "500", "600", "700"],
|
|
15
|
+
});
|
|
12
16
|
|
|
13
17
|
export const metadata: Metadata = {
|
|
14
|
-
title:
|
|
15
|
-
description:
|
|
18
|
+
title: branding.projectName,
|
|
19
|
+
description: branding.description,
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
export default async function RootLayout({
|