@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.
Files changed (41) hide show
  1. package/README.md +6 -2
  2. package/dist/cli.js +216 -75
  3. package/package.json +2 -1
  4. package/templates/next-base/PROJECT_STRUCTURE.md +88 -0
  5. package/templates/next-base/SETUP.md +86 -0
  6. package/templates/next-base/bun.lock +1443 -0
  7. package/templates/next-base/messages/vi/auth.json +18 -4
  8. package/templates/next-base/next-env.d.ts +3 -1
  9. package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +104 -0
  10. package/templates/next-base/prisma/migrations/migration_lock.toml +3 -0
  11. package/templates/next-base/prisma/schema.prisma +23 -9
  12. package/templates/next-base/public/logo.svg +4 -0
  13. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  14. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  15. package/templates/next-base/src/app/(auth)/layout.tsx +3 -3
  16. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  17. package/templates/next-base/src/app/(dashboard)/layout.tsx +13 -1
  18. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  19. package/templates/next-base/src/app/api/v1/auth/login/route.ts +15 -5
  20. package/templates/next-base/src/app/api/v1/auth/me/route.ts +17 -19
  21. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  22. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  23. package/templates/next-base/src/app/globals.css +4 -0
  24. package/templates/next-base/src/app/layout.tsx +7 -3
  25. package/templates/next-base/src/components/branding/logo.tsx +27 -0
  26. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +3 -4
  27. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +2 -1
  28. package/templates/next-base/src/config/branding.ts +14 -0
  29. package/templates/next-base/src/features/auth/components/account-panel.tsx +12 -7
  30. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  31. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +18 -13
  32. package/templates/next-base/src/features/auth/validations.ts +7 -1
  33. package/templates/next-base/src/features/users/services.ts +132 -0
  34. package/templates/next-base/src/features/users/validations.ts +21 -0
  35. package/templates/next-base/src/instrumentation.ts +14 -0
  36. package/templates/next-base/src/lib/auth-client.ts +2 -2
  37. package/templates/next-base/src/lib/auth.ts +2 -2
  38. package/templates/next-base/src/lib/bootstrap.ts +96 -0
  39. package/templates/next-base/src/lib/constants.ts +7 -0
  40. package/templates/next-base/src/lib/rbac.ts +62 -0
  41. 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="/" className="font-semibold">
39
- {t("appName")}
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
- <p className="font-semibold">__PROJECT_NAME__</p>
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 { publicApi } from "@/lib/axios-instance";
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 SessionPayload = {
9
+ type MePayload = {
10
10
  user?: {
11
11
  id: string;
12
- email: string;
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<SessionPayload | null>(null);
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 publicApi.get("/api/v1/auth/me", {
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<SessionPayload>).data);
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("email")}: {session.user.email}
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 [email, setEmail] = useState("");
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({ email, password });
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 accessToken = (response.data as ApiSuccess<{ accessToken: string }>)
39
- .data?.accessToken;
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("/account");
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="email">{t("email")}</Label>
66
+ <Label htmlFor="username">{t("username")}</Label>
63
67
  <Input
64
- id="email"
65
- type="email"
66
- value={email}
67
- onChange={(event) => setEmail(event.target.value)}
68
- placeholder={t("emailPlaceholder")}
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
- email: z.string().email(),
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,7 @@
1
+ /** Built-in super-admin account username — protected from RBAC mutations. */
2
+ export const SUPER_ADMIN_USERNAME = "admin";
3
+
4
+ export const ADMIN_ROLE_NAME = "admin";
5
+ export const ADMIN_ROLE_LEVEL = 100;
6
+
7
+ export const INTERNAL_EMAIL_DOMAIN = "internal.local";
@@ -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
+ }