@thinhnguyencth1204/nextcli 0.3.0 → 0.4.1
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 +216 -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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
import { branding } from "@/config/branding";
|
|
3
|
+
import { cn } from "@/utils/cn";
|
|
4
|
+
|
|
5
|
+
type LogoProps = {
|
|
6
|
+
className?: string;
|
|
7
|
+
size?: number;
|
|
8
|
+
showLabel?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function Logo({ className, size = 28, showLabel = false }: LogoProps) {
|
|
12
|
+
return (
|
|
13
|
+
<span className={cn("inline-flex items-center gap-2", className)}>
|
|
14
|
+
<Image
|
|
15
|
+
src={branding.logoPath}
|
|
16
|
+
alt={branding.projectName}
|
|
17
|
+
width={size}
|
|
18
|
+
height={size}
|
|
19
|
+
className="shrink-0"
|
|
20
|
+
priority
|
|
21
|
+
/>
|
|
22
|
+
{showLabel ? (
|
|
23
|
+
<span className="font-semibold">{branding.projectName}</span>
|
|
24
|
+
) : null}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -2,19 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
-
import { useTranslations } from "next-intl";
|
|
6
5
|
import {
|
|
7
6
|
useSidebar,
|
|
8
7
|
Sidebar,
|
|
9
8
|
SidebarHeader,
|
|
10
9
|
SidebarTrigger,
|
|
11
10
|
} from "@/components/ui/sidebar";
|
|
11
|
+
import { Logo } from "@/components/branding/logo";
|
|
12
12
|
import { NavSidebar } from "@/components/layout/private/nav-sidebar";
|
|
13
13
|
import { cn } from "@/utils/cn";
|
|
14
14
|
|
|
15
15
|
export function AppSidebar() {
|
|
16
16
|
const { state, isMobile } = useSidebar();
|
|
17
|
-
const t = useTranslations("common");
|
|
18
17
|
const isCollapsed = state === "collapsed";
|
|
19
18
|
|
|
20
19
|
return (
|
|
@@ -35,8 +34,8 @@ export function AppSidebar() {
|
|
|
35
34
|
</div>
|
|
36
35
|
)}
|
|
37
36
|
<SidebarHeader className="p-3">
|
|
38
|
-
<Link href="/"
|
|
39
|
-
|
|
37
|
+
<Link href="/dashboard">
|
|
38
|
+
<Logo showLabel />
|
|
40
39
|
</Link>
|
|
41
40
|
</SidebarHeader>
|
|
42
41
|
<NavSidebar />
|
|
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|
|
6
6
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
7
7
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
8
8
|
import { Button } from "@/components/ui/button";
|
|
9
|
+
import { Logo } from "@/components/branding/logo";
|
|
9
10
|
import { AppSidebar } from "@/components/layout/private/app-sidebar";
|
|
10
11
|
import { NavUser } from "@/components/layout/private/nav-user";
|
|
11
12
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
@@ -21,7 +22,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
|
21
22
|
<header className="flex h-14 items-center justify-between border-b bg-card px-4">
|
|
22
23
|
<div className="flex items-center gap-2">
|
|
23
24
|
{isMobile && <SidebarTrigger />}
|
|
24
|
-
<
|
|
25
|
+
<Logo showLabel />
|
|
25
26
|
</div>
|
|
26
27
|
|
|
27
28
|
<div className="flex items-center gap-2">
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central branding config — edit these values after scaffolding.
|
|
3
|
+
* See SETUP.md § Branding for env vs display settings.
|
|
4
|
+
*/
|
|
5
|
+
export const branding = {
|
|
6
|
+
/** Display name shown in UI (header, metadata, sidebar). */
|
|
7
|
+
projectName: "__PROJECT_NAME__",
|
|
8
|
+
/** URL-safe slug (package name, DB name). Set at create time. */
|
|
9
|
+
projectSlug: "__PROJECT_NAME__",
|
|
10
|
+
/** Short app description for metadata. */
|
|
11
|
+
description: "Outsource-ready Next.js scaffolded by NexTCLI",
|
|
12
|
+
/** Public path to logo asset under /public. */
|
|
13
|
+
logoPath: "/logo.svg",
|
|
14
|
+
} as const;
|
|
@@ -2,32 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
|
-
import {
|
|
5
|
+
import { protectedApi } from "@/lib/axios-instance";
|
|
6
6
|
import type { ApiSuccess } from "@/types";
|
|
7
7
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
8
|
|
|
9
|
-
type
|
|
9
|
+
type MePayload = {
|
|
10
10
|
user?: {
|
|
11
11
|
id: string;
|
|
12
|
-
|
|
12
|
+
username: string;
|
|
13
|
+
email?: string | null;
|
|
13
14
|
name?: string | null;
|
|
15
|
+
role?: { name: string; level: number } | null;
|
|
14
16
|
};
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
export function AccountPanel() {
|
|
18
20
|
const t = useTranslations("auth.account");
|
|
19
|
-
const [session, setSession] = useState<
|
|
21
|
+
const [session, setSession] = useState<MePayload | null>(null);
|
|
20
22
|
const [loading, setLoading] = useState(true);
|
|
21
23
|
|
|
22
24
|
useEffect(() => {
|
|
23
25
|
let mounted = true;
|
|
24
26
|
const run = async () => {
|
|
25
27
|
try {
|
|
26
|
-
const response = await
|
|
28
|
+
const response = await protectedApi.get("/api/v1/auth/me", {
|
|
27
29
|
withCredentials: true,
|
|
28
30
|
});
|
|
29
31
|
if (mounted) {
|
|
30
|
-
setSession((response.data as ApiSuccess<
|
|
32
|
+
setSession((response.data as ApiSuccess<MePayload>).data);
|
|
31
33
|
}
|
|
32
34
|
} catch {
|
|
33
35
|
if (mounted) {
|
|
@@ -64,7 +66,10 @@ export function AccountPanel() {
|
|
|
64
66
|
{t("userId")}: {session.user.id}
|
|
65
67
|
</p>
|
|
66
68
|
<p>
|
|
67
|
-
{t("
|
|
69
|
+
{t("username")}: {session.user.username}
|
|
70
|
+
</p>
|
|
71
|
+
<p>
|
|
72
|
+
{t("email")}: {session.user.email ?? t("na")}
|
|
68
73
|
</p>
|
|
69
74
|
<p>
|
|
70
75
|
{t("name")}: {session.user.name ?? t("na")}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { FormEvent } from "react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { useTranslations } from "next-intl";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import { protectedApi } from "@/lib/axios-instance";
|
|
9
|
+
import { changePasswordSchema } from "@/features/auth/validations";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Label } from "@/components/ui/label";
|
|
14
|
+
|
|
15
|
+
export function ChangePasswordForm() {
|
|
16
|
+
const router = useRouter();
|
|
17
|
+
const t = useTranslations("auth.changePasswordForm");
|
|
18
|
+
const [currentPassword, setCurrentPassword] = useState("");
|
|
19
|
+
const [newPassword, setNewPassword] = useState("");
|
|
20
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
21
|
+
|
|
22
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
|
|
25
|
+
const parsed = changePasswordSchema.safeParse({
|
|
26
|
+
currentPassword,
|
|
27
|
+
newPassword,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
toast.error(t("invalidInput"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
setIsSubmitting(true);
|
|
37
|
+
await protectedApi.post("/api/v1/auth/change-password", parsed.data, {
|
|
38
|
+
withCredentials: true,
|
|
39
|
+
});
|
|
40
|
+
toast.success(t("success"));
|
|
41
|
+
router.push("/dashboard");
|
|
42
|
+
router.refresh();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : t("failed");
|
|
45
|
+
toast.error(message);
|
|
46
|
+
} finally {
|
|
47
|
+
setIsSubmitting(false);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Card>
|
|
53
|
+
<CardContent className="pt-6">
|
|
54
|
+
<form onSubmit={onSubmit} className="grid gap-4">
|
|
55
|
+
<div className="grid gap-2">
|
|
56
|
+
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
|
|
57
|
+
<Input
|
|
58
|
+
id="currentPassword"
|
|
59
|
+
type="password"
|
|
60
|
+
value={currentPassword}
|
|
61
|
+
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
62
|
+
required
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="grid gap-2">
|
|
66
|
+
<Label htmlFor="newPassword">{t("newPassword")}</Label>
|
|
67
|
+
<Input
|
|
68
|
+
id="newPassword"
|
|
69
|
+
type="password"
|
|
70
|
+
value={newPassword}
|
|
71
|
+
onChange={(event) => setNewPassword(event.target.value)}
|
|
72
|
+
required
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
76
|
+
{isSubmitting ? t("submitting") : t("submit")}
|
|
77
|
+
</Button>
|
|
78
|
+
</form>
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -14,17 +14,22 @@ import { Card, CardContent } from "@/components/ui/card";
|
|
|
14
14
|
import { Input } from "@/components/ui/input";
|
|
15
15
|
import { Label } from "@/components/ui/label";
|
|
16
16
|
|
|
17
|
+
type LoginResponse = {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
requirePasswordChange: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
17
22
|
export function SignInForm() {
|
|
18
23
|
const router = useRouter();
|
|
19
24
|
const t = useTranslations("auth.signInForm");
|
|
20
|
-
const [
|
|
25
|
+
const [username, setUsername] = useState("");
|
|
21
26
|
const [password, setPassword] = useState("");
|
|
22
27
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
23
28
|
|
|
24
29
|
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
25
30
|
event.preventDefault();
|
|
26
31
|
|
|
27
|
-
const parsed = signInSchema.safeParse({
|
|
32
|
+
const parsed = signInSchema.safeParse({ username, password });
|
|
28
33
|
if (!parsed.success) {
|
|
29
34
|
toast.error(t("invalidInput"));
|
|
30
35
|
return;
|
|
@@ -35,16 +40,15 @@ export function SignInForm() {
|
|
|
35
40
|
const response = await publicApi.post("/api/v1/auth/login", parsed.data, {
|
|
36
41
|
withCredentials: true,
|
|
37
42
|
});
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (!accessToken) {
|
|
43
|
+
const payload = (response.data as ApiSuccess<LoginResponse>).data;
|
|
44
|
+
if (!payload?.accessToken) {
|
|
41
45
|
toast.error(t("missingAccessToken"));
|
|
42
46
|
return;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
setAccessToken(accessToken);
|
|
49
|
+
setAccessToken(payload.accessToken);
|
|
46
50
|
toast.success(t("success"));
|
|
47
|
-
router.push("/
|
|
51
|
+
router.push(payload.requirePasswordChange ? "/change-password" : "/dashboard");
|
|
48
52
|
router.refresh();
|
|
49
53
|
} catch (error) {
|
|
50
54
|
const message = error instanceof Error ? error.message : t("failed");
|
|
@@ -59,13 +63,13 @@ export function SignInForm() {
|
|
|
59
63
|
<CardContent className="pt-6">
|
|
60
64
|
<form onSubmit={onSubmit} className="grid gap-4">
|
|
61
65
|
<div className="grid gap-2">
|
|
62
|
-
<Label htmlFor="
|
|
66
|
+
<Label htmlFor="username">{t("username")}</Label>
|
|
63
67
|
<Input
|
|
64
|
-
id="
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
id="username"
|
|
69
|
+
value={username}
|
|
70
|
+
onChange={(event) => setUsername(event.target.value)}
|
|
71
|
+
placeholder={t("usernamePlaceholder")}
|
|
72
|
+
autoComplete="username"
|
|
69
73
|
required
|
|
70
74
|
/>
|
|
71
75
|
</div>
|
|
@@ -77,6 +81,7 @@ export function SignInForm() {
|
|
|
77
81
|
value={password}
|
|
78
82
|
onChange={(event) => setPassword(event.target.value)}
|
|
79
83
|
placeholder={t("passwordPlaceholder")}
|
|
84
|
+
autoComplete="current-password"
|
|
80
85
|
required
|
|
81
86
|
/>
|
|
82
87
|
</div>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
export const signInSchema = z.object({
|
|
4
|
-
|
|
4
|
+
username: z.string().min(3).max(30),
|
|
5
5
|
password: z.string().min(8),
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
+
export const changePasswordSchema = z.object({
|
|
9
|
+
currentPassword: z.string().min(8),
|
|
10
|
+
newPassword: z.string().min(8),
|
|
11
|
+
});
|
|
12
|
+
|
|
8
13
|
export type SignInInput = z.infer<typeof signInSchema>;
|
|
14
|
+
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Role, User } from "@prisma/client";
|
|
2
|
+
import { INTERNAL_EMAIL_DOMAIN, SUPER_ADMIN_USERNAME } from "@/lib/constants";
|
|
3
|
+
import { auth } from "@/lib/auth";
|
|
4
|
+
import prisma from "@/lib/prisma";
|
|
5
|
+
import type { CreateUserInput, UpdateUserInput } from "@/features/users/validations";
|
|
6
|
+
|
|
7
|
+
export type UserWithRole = User & { role: Role | null };
|
|
8
|
+
|
|
9
|
+
function toPublicUser(user: UserWithRole) {
|
|
10
|
+
return {
|
|
11
|
+
id: user.id,
|
|
12
|
+
username: user.username,
|
|
13
|
+
displayUsername: user.displayUsername,
|
|
14
|
+
name: user.name,
|
|
15
|
+
email: user.email,
|
|
16
|
+
requirePasswordChange: user.requirePasswordChange,
|
|
17
|
+
role: user.role
|
|
18
|
+
? { id: user.role.id, name: user.role.name, level: user.role.level }
|
|
19
|
+
: null,
|
|
20
|
+
createdAt: user.createdAt,
|
|
21
|
+
updatedAt: user.updatedAt,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listUsersForActor(actorLevel: number) {
|
|
26
|
+
const users = await prisma.user.findMany({
|
|
27
|
+
where: {
|
|
28
|
+
username: { not: SUPER_ADMIN_USERNAME },
|
|
29
|
+
role: {
|
|
30
|
+
level: { lt: actorLevel },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
include: { role: true },
|
|
34
|
+
orderBy: { createdAt: "desc" },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return users.map(toPublicUser);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getUserByIdForActor(
|
|
41
|
+
id: string,
|
|
42
|
+
actorLevel: number,
|
|
43
|
+
): Promise<ReturnType<typeof toPublicUser> | null> {
|
|
44
|
+
const user = await prisma.user.findFirst({
|
|
45
|
+
where: {
|
|
46
|
+
id,
|
|
47
|
+
username: { not: SUPER_ADMIN_USERNAME },
|
|
48
|
+
role: {
|
|
49
|
+
level: { lt: actorLevel },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
include: { role: true },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return user ? toPublicUser(user) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function createUserRecord(
|
|
59
|
+
input: CreateUserInput,
|
|
60
|
+
options?: { requirePasswordChange?: boolean },
|
|
61
|
+
) {
|
|
62
|
+
const placeholderEmail =
|
|
63
|
+
input.email ?? `${input.username}@${INTERNAL_EMAIL_DOMAIN}`;
|
|
64
|
+
|
|
65
|
+
await auth.api.signUpEmail({
|
|
66
|
+
body: {
|
|
67
|
+
email: placeholderEmail,
|
|
68
|
+
password: input.password,
|
|
69
|
+
name: input.name ?? input.username,
|
|
70
|
+
username: input.username,
|
|
71
|
+
displayUsername: input.username,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const created = await prisma.user.findUnique({
|
|
76
|
+
where: { username: input.username },
|
|
77
|
+
include: { role: true },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!created) {
|
|
81
|
+
throw new Error("USER_CREATE_FAILED");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const updated = await prisma.user.update({
|
|
85
|
+
where: { id: created.id },
|
|
86
|
+
data: {
|
|
87
|
+
roleId: input.roleId,
|
|
88
|
+
email: input.email ?? placeholderEmail,
|
|
89
|
+
requirePasswordChange: options?.requirePasswordChange ?? input.requirePasswordChange ?? false,
|
|
90
|
+
},
|
|
91
|
+
include: { role: true },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return toPublicUser(updated);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function updateUserRecord(id: string, input: UpdateUserInput) {
|
|
98
|
+
const existing = await prisma.user.findUnique({
|
|
99
|
+
where: { id },
|
|
100
|
+
include: { role: true },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!existing) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (input.password) {
|
|
108
|
+
const { hashPassword } = await import("better-auth/crypto");
|
|
109
|
+
const hashed = await hashPassword(input.password);
|
|
110
|
+
await prisma.account.updateMany({
|
|
111
|
+
where: { userId: id, providerId: "credential" },
|
|
112
|
+
data: { password: hashed },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const updated = await prisma.user.update({
|
|
117
|
+
where: { id },
|
|
118
|
+
data: {
|
|
119
|
+
name: input.name,
|
|
120
|
+
email: input.email,
|
|
121
|
+
roleId: input.roleId,
|
|
122
|
+
requirePasswordChange: input.requirePasswordChange,
|
|
123
|
+
},
|
|
124
|
+
include: { role: true },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return toPublicUser(updated);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function deleteUserRecord(id: string) {
|
|
131
|
+
await prisma.user.delete({ where: { id } });
|
|
132
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const createUserSchema = z.object({
|
|
4
|
+
username: z.string().min(3).max(30),
|
|
5
|
+
password: z.string().min(8),
|
|
6
|
+
name: z.string().min(1).max(120).optional(),
|
|
7
|
+
email: z.string().email().optional(),
|
|
8
|
+
roleId: z.string().min(1),
|
|
9
|
+
requirePasswordChange: z.boolean().optional(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const updateUserSchema = z.object({
|
|
13
|
+
name: z.string().min(1).max(120).optional(),
|
|
14
|
+
email: z.string().email().nullable().optional(),
|
|
15
|
+
roleId: z.string().min(1).optional(),
|
|
16
|
+
password: z.string().min(8).optional(),
|
|
17
|
+
requirePasswordChange: z.boolean().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type CreateUserInput = z.infer<typeof createUserSchema>;
|
|
21
|
+
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function register() {
|
|
2
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") {
|
|
3
|
+
return;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
const { runBootstrap } = await import("@/lib/bootstrap");
|
|
8
|
+
await runBootstrap();
|
|
9
|
+
} catch (error) {
|
|
10
|
+
const message =
|
|
11
|
+
error instanceof Error ? error.message : "Unknown instrumentation error";
|
|
12
|
+
console.warn(`[instrumentation] Bootstrap failed: ${message}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createAuthClient } from "better-auth/react";
|
|
2
|
-
import { jwtClient } from "better-auth/client/plugins";
|
|
2
|
+
import { jwtClient, usernameClient } from "better-auth/client/plugins";
|
|
3
3
|
|
|
4
4
|
export const authClient = createAuthClient({
|
|
5
5
|
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
|
6
|
-
plugins: [jwtClient()],
|
|
6
|
+
plugins: [jwtClient(), usernameClient()],
|
|
7
7
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { betterAuth } from "better-auth/minimal";
|
|
2
2
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
3
|
-
import { jwt } from "better-auth/plugins";
|
|
3
|
+
import { jwt, username } from "better-auth/plugins";
|
|
4
4
|
import prisma from "@/lib/prisma";
|
|
5
5
|
|
|
6
6
|
const socialProviders = {
|
|
@@ -12,7 +12,7 @@ export const auth = betterAuth({
|
|
|
12
12
|
database: prismaAdapter(prisma, {
|
|
13
13
|
provider: "postgresql",
|
|
14
14
|
}),
|
|
15
|
-
plugins: [jwt()],
|
|
15
|
+
plugins: [jwt(), username()],
|
|
16
16
|
emailAndPassword: {
|
|
17
17
|
enabled: true,
|
|
18
18
|
},
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { PrismaClient } from "@prisma/client";
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_ROLE_LEVEL,
|
|
4
|
+
ADMIN_ROLE_NAME,
|
|
5
|
+
INTERNAL_EMAIL_DOMAIN,
|
|
6
|
+
SUPER_ADMIN_USERNAME,
|
|
7
|
+
} from "@/lib/constants";
|
|
8
|
+
import { auth } from "@/lib/auth";
|
|
9
|
+
import prisma from "@/lib/prisma";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ADMIN_PASSWORD = "admin";
|
|
12
|
+
|
|
13
|
+
function hasRoleModel(client: PrismaClient): boolean {
|
|
14
|
+
const delegate = (
|
|
15
|
+
client as PrismaClient & { role?: { findUnique?: unknown } }
|
|
16
|
+
).role;
|
|
17
|
+
return typeof delegate?.findUnique === "function";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function logBootstrapSkip(message: string): void {
|
|
21
|
+
console.warn(`[bootstrap] ${message}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runBootstrap(): Promise<void> {
|
|
25
|
+
if (!hasRoleModel(prisma)) {
|
|
26
|
+
logBootstrapSkip(
|
|
27
|
+
"Prisma client is missing the Role model. Run: bun run db:generate && bun run db:migrate",
|
|
28
|
+
);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
let adminRole = await prisma.role.findUnique({
|
|
34
|
+
where: { name: ADMIN_ROLE_NAME },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!adminRole) {
|
|
38
|
+
adminRole = await prisma.role.create({
|
|
39
|
+
data: {
|
|
40
|
+
name: ADMIN_ROLE_NAME,
|
|
41
|
+
level: ADMIN_ROLE_LEVEL,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const existingAdmin = await prisma.user.findUnique({
|
|
47
|
+
where: { username: SUPER_ADMIN_USERNAME },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (existingAdmin) {
|
|
51
|
+
if (!existingAdmin.roleId) {
|
|
52
|
+
await prisma.user.update({
|
|
53
|
+
where: { id: existingAdmin.id },
|
|
54
|
+
data: { roleId: adminRole.id },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await auth.api.signUpEmail({
|
|
62
|
+
body: {
|
|
63
|
+
email: `${SUPER_ADMIN_USERNAME}@${INTERNAL_EMAIL_DOMAIN}`,
|
|
64
|
+
password: DEFAULT_ADMIN_PASSWORD,
|
|
65
|
+
name: "Administrator",
|
|
66
|
+
username: SUPER_ADMIN_USERNAME,
|
|
67
|
+
displayUsername: SUPER_ADMIN_USERNAME,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
// User may already exist from a partial bootstrap run.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const adminUser = await prisma.user.findUnique({
|
|
75
|
+
where: { username: SUPER_ADMIN_USERNAME },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!adminUser) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await prisma.user.update({
|
|
83
|
+
where: { id: adminUser.id },
|
|
84
|
+
data: {
|
|
85
|
+
roleId: adminRole.id,
|
|
86
|
+
requirePasswordChange: true,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message =
|
|
91
|
+
error instanceof Error ? error.message : "Unknown bootstrap error";
|
|
92
|
+
logBootstrapSkip(
|
|
93
|
+
`Skipped admin seed (${message}). Ensure Postgres is running and run: bun run db:migrate`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import type { Role, User } from "@prisma/client";
|
|
3
|
+
import { auth } from "@/lib/auth";
|
|
4
|
+
import prisma from "@/lib/prisma";
|
|
5
|
+
import { SUPER_ADMIN_USERNAME } from "@/lib/constants";
|
|
6
|
+
|
|
7
|
+
export type SessionUser = User & { role: Role | null };
|
|
8
|
+
|
|
9
|
+
export async function getSessionUser(
|
|
10
|
+
requestHeaders?: Headers,
|
|
11
|
+
): Promise<SessionUser | null> {
|
|
12
|
+
const session = await auth.api.getSession({
|
|
13
|
+
headers: requestHeaders ?? (await headers()),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!session?.user?.id) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return prisma.user.findUnique({
|
|
21
|
+
where: { id: session.user.id },
|
|
22
|
+
include: { role: true },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSuperAdmin(user: Pick<User, "username">): boolean {
|
|
27
|
+
return user.username === SUPER_ADMIN_USERNAME;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function canActOnUser(
|
|
31
|
+
actor: SessionUser,
|
|
32
|
+
target: SessionUser,
|
|
33
|
+
): boolean {
|
|
34
|
+
if (isSuperAdmin(target)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!actor.role || !target.role) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return actor.role.level > target.role.level;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function canAssignRole(
|
|
46
|
+
actor: SessionUser,
|
|
47
|
+
targetRole: Pick<Role, "level">,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (!actor.role) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return actor.role.level > targetRole.level;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function requireAuthenticated(
|
|
57
|
+
user: SessionUser | null,
|
|
58
|
+
): asserts user is SessionUser {
|
|
59
|
+
if (!user) {
|
|
60
|
+
throw new Error("UNAUTHORIZED");
|
|
61
|
+
}
|
|
62
|
+
}
|