@thinhnguyencth1204/nextcli 0.7.0 → 0.9.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.
Files changed (114) hide show
  1. package/README.md +37 -24
  2. package/dist/cli.js +168 -107
  3. package/package.json +5 -3
  4. package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
  5. package/templates/next-base/.env +16 -0
  6. package/templates/next-base/.env.development +16 -0
  7. package/templates/next-base/.env.example +16 -0
  8. package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
  9. package/templates/next-base/SETUP.md +62 -10
  10. package/templates/next-base/bun.lock +59 -414
  11. package/templates/next-base/messages/vi/auth.json +42 -0
  12. package/templates/next-base/messages/vi/common.json +34 -0
  13. package/templates/next-base/messages/vi/example.json +10 -0
  14. package/templates/next-base/next-env.d.ts +1 -1
  15. package/templates/next-base/next.config.ts +4 -1
  16. package/templates/next-base/nextcli.json +12 -4
  17. package/templates/next-base/package.json +25 -1
  18. package/templates/next-base/prisma/schema.prisma +84 -0
  19. package/templates/next-base/prisma.config.ts +16 -0
  20. package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
  21. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  22. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  23. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  25. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
  26. package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
  27. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  28. package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
  29. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  30. package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  31. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  32. package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
  33. package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
  34. package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
  35. package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
  36. package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
  37. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  38. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  39. package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
  40. package/templates/next-base/src/app/globals.css +57 -0
  41. package/templates/next-base/src/app/layout.tsx +14 -6
  42. package/templates/next-base/src/app/page.tsx +2 -25
  43. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  44. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  45. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  46. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  47. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  48. package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
  49. package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
  50. package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
  51. package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
  52. package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
  53. package/templates/next-base/src/components/rich-text/index.ts +11 -0
  54. package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
  55. package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
  56. package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
  57. package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
  58. package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
  59. package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
  60. package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
  61. package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
  62. package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
  63. package/templates/next-base/src/components/rich-text/types.ts +60 -0
  64. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  65. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  66. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  67. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  68. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  69. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  70. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  71. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  72. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  73. package/templates/next-base/src/example/api/use-example.ts +21 -0
  74. package/templates/next-base/src/example/api/use-mutations.ts +20 -0
  75. package/templates/next-base/src/example/components/example-table.tsx +51 -0
  76. package/templates/next-base/src/example/services.ts +9 -0
  77. package/templates/next-base/src/example/validations.ts +8 -0
  78. package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
  79. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  80. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
  81. package/templates/next-base/src/features/auth/validations.ts +14 -0
  82. package/templates/next-base/src/features/users/services.ts +132 -0
  83. package/templates/next-base/src/features/users/validations.ts +21 -0
  84. package/templates/next-base/src/hooks/index.ts +1 -1
  85. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  86. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  87. package/templates/next-base/src/i18n/config.ts +7 -0
  88. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  89. package/templates/next-base/src/i18n/request.ts +25 -0
  90. package/templates/next-base/src/instrumentation.ts +14 -0
  91. package/templates/next-base/src/lib/api/axios.ts +145 -0
  92. package/templates/next-base/src/lib/api/response.ts +45 -0
  93. package/templates/next-base/src/lib/api/token-store.ts +13 -0
  94. package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
  95. package/templates/next-base/src/lib/auth/client.ts +7 -0
  96. package/templates/next-base/src/lib/auth/cookies.ts +15 -0
  97. package/templates/next-base/src/lib/auth/index.ts +1 -0
  98. package/templates/next-base/src/lib/auth/rbac.ts +59 -0
  99. package/templates/next-base/src/lib/auth/server.ts +21 -0
  100. package/templates/next-base/src/lib/constants.ts +10 -0
  101. package/templates/next-base/src/lib/db/prisma.ts +23 -0
  102. package/templates/next-base/src/lib/prisma.ts +23 -0
  103. package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
  104. package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
  105. package/templates/next-base/src/lib/rich-text/index.ts +12 -0
  106. package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
  107. package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
  108. package/templates/next-base/src/lib/supabase/client.ts +6 -0
  109. package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
  110. package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
  111. package/templates/next-base/src/lib/supabase/storage.ts +164 -0
  112. package/templates/next-base/src/types/data-table.ts +4 -0
  113. package/templates/next-base/src/types/index.ts +0 -2
  114. package/templates/next-base/tsconfig.tsbuildinfo +1 -0
@@ -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/db/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>;
@@ -1 +1 @@
1
- export * from "@/hooks/use-mobile";
1
+ /** Re-export hooks from optional modules here when needed. */
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ getCoreRowModel,
6
+ getPaginationRowModel,
7
+ type ColumnDef,
8
+ useReactTable,
9
+ } from "@tanstack/react-table";
10
+ import type { DataTablePaginationState } from "@/types/data-table";
11
+
12
+ export function useDataTable<TData>({
13
+ data,
14
+ columns,
15
+ initialState,
16
+ }: {
17
+ data: TData[];
18
+ columns: ColumnDef<TData, unknown>[];
19
+ initialState?: DataTablePaginationState;
20
+ }) {
21
+ const [pagination, setPagination] = React.useState<DataTablePaginationState>(
22
+ initialState ?? { pageIndex: 0, pageSize: 10 },
23
+ );
24
+
25
+ return useReactTable({
26
+ data,
27
+ columns,
28
+ state: { pagination },
29
+ onPaginationChange: setPagination,
30
+ getCoreRowModel: getCoreRowModel(),
31
+ getPaginationRowModel: getPaginationRowModel(),
32
+ });
33
+ }
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ const MOBILE_BREAKPOINT = 768;
6
+
7
+ export function useIsMobile() {
8
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
9
+ undefined,
10
+ );
11
+
12
+ React.useEffect(() => {
13
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
14
+ const onChange = () => {
15
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
16
+ };
17
+
18
+ mql.addEventListener("change", onChange);
19
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
20
+
21
+ return () => mql.removeEventListener("change", onChange);
22
+ }, []);
23
+
24
+ return !!isMobile;
25
+ }
@@ -0,0 +1,7 @@
1
+ // nextcli:locales:start
2
+ export const locales = ["vi"] as const;
3
+ // nextcli:locales:end
4
+
5
+ export const defaultLocale = "vi";
6
+
7
+ export type AppLocale = (typeof locales)[number];
@@ -0,0 +1,5 @@
1
+ // nextcli:namespaces:start
2
+ export const namespaces = ["common", "auth", "example"] as const;
3
+ // nextcli:namespaces:end
4
+
5
+ export type MessageNamespace = (typeof namespaces)[number];
@@ -0,0 +1,25 @@
1
+ import { getRequestConfig } from "next-intl/server";
2
+ import { cookies } from "next/headers";
3
+ import { defaultLocale, locales, type AppLocale } from "@/i18n/config";
4
+ import { namespaces } from "@/i18n/namespaces";
5
+
6
+ export default getRequestConfig(async () => {
7
+ const cookieStore = await cookies();
8
+ const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
9
+ const locale = (
10
+ locales.includes(cookieLocale as AppLocale) ? cookieLocale : defaultLocale
11
+ ) as AppLocale;
12
+
13
+ const namespaceMessages = await Promise.all(
14
+ namespaces.map(async (namespace) => {
15
+ const file = (await import(`../../messages/${locale}/${namespace}.json`))
16
+ .default;
17
+ return [namespace, file] as const;
18
+ }),
19
+ );
20
+
21
+ return {
22
+ locale,
23
+ messages: Object.fromEntries(namespaceMessages),
24
+ };
25
+ });
@@ -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/auth/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
+ }
@@ -0,0 +1,145 @@
1
+ import axios from "axios";
2
+ import {
3
+ clearAccessToken,
4
+ getAccessToken,
5
+ setAccessToken,
6
+ } from "@/lib/api/token-store";
7
+ import type { ApiErrorResponse, ApiSuccess } from "@/types";
8
+
9
+ const baseURL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
10
+
11
+ type RetryableRequestConfig = {
12
+ _retry?: boolean;
13
+ };
14
+
15
+ export const publicApi = axios.create({
16
+ baseURL,
17
+ withCredentials: true,
18
+ headers: {
19
+ "Content-Type": "application/json",
20
+ },
21
+ });
22
+
23
+ export const protectedApi = axios.create({
24
+ baseURL,
25
+ withCredentials: true,
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ },
29
+ });
30
+
31
+ protectedApi.interceptors.request.use((config) => {
32
+ const token = getAccessToken();
33
+ if (token) {
34
+ config.headers = config.headers ?? {};
35
+ config.headers.Authorization = `Bearer ${token}`;
36
+ }
37
+ return config;
38
+ });
39
+
40
+ let isRefreshing = false;
41
+ let waitingQueue: Array<(token: string | null) => void> = [];
42
+
43
+ function flushQueue(token: string | null): void {
44
+ for (const resolve of waitingQueue) {
45
+ resolve(token);
46
+ }
47
+ waitingQueue = [];
48
+ }
49
+
50
+ async function refreshAccessToken(): Promise<string | null> {
51
+ try {
52
+ const response = await publicApi.post("/api/v1/auth/refresh", null, {
53
+ withCredentials: true,
54
+ });
55
+ const token = (response.data as ApiSuccess<{ accessToken: string }>).data
56
+ ?.accessToken;
57
+ if (!token) {
58
+ clearAccessToken();
59
+ return null;
60
+ }
61
+ setAccessToken(token);
62
+ return token;
63
+ } catch {
64
+ clearAccessToken();
65
+ return null;
66
+ }
67
+ }
68
+
69
+ protectedApi.interceptors.response.use(
70
+ (response) => response,
71
+ async (error) => {
72
+ const config = error.config as typeof error.config & RetryableRequestConfig;
73
+ if (!config || config._retry || error.response?.status !== 401) {
74
+ return Promise.reject(error);
75
+ }
76
+
77
+ if (isRefreshing) {
78
+ return new Promise((resolve, reject) => {
79
+ waitingQueue.push((token) => {
80
+ if (!token) {
81
+ reject(error);
82
+ return;
83
+ }
84
+
85
+ config.headers = config.headers ?? {};
86
+ config.headers.Authorization = `Bearer ${token}`;
87
+ resolve(protectedApi(config));
88
+ });
89
+ });
90
+ }
91
+
92
+ config._retry = true;
93
+ isRefreshing = true;
94
+
95
+ try {
96
+ const token = await refreshAccessToken();
97
+ flushQueue(token);
98
+ if (!token) {
99
+ return Promise.reject(error);
100
+ }
101
+
102
+ config.headers = config.headers ?? {};
103
+ config.headers.Authorization = `Bearer ${token}`;
104
+ return protectedApi(config);
105
+ } finally {
106
+ isRefreshing = false;
107
+ }
108
+ },
109
+ );
110
+
111
+ function extractApiErrorMessage(error: unknown): string {
112
+ if (!axios.isAxiosError(error)) {
113
+ return "Unexpected error occurred.";
114
+ }
115
+
116
+ const payload = error.response?.data as ApiErrorResponse | undefined;
117
+ if (payload && payload.success === false) {
118
+ return payload.error.message;
119
+ }
120
+
121
+ return error.message || "Request failed.";
122
+ }
123
+
124
+ publicApi.interceptors.response.use(
125
+ (response) => response,
126
+ async (error) => {
127
+ if (axios.isAxiosError(error)) {
128
+ error.message = extractApiErrorMessage(error);
129
+ }
130
+ return Promise.reject(error);
131
+ },
132
+ );
133
+
134
+ protectedApi.interceptors.response.use(
135
+ (response) => response,
136
+ async (error) => {
137
+ if (axios.isAxiosError(error)) {
138
+ error.message = extractApiErrorMessage(error);
139
+ }
140
+ return Promise.reject(error);
141
+ },
142
+ );
143
+
144
+ export const api = publicApi;
145
+ export { extractApiErrorMessage };
@@ -0,0 +1,45 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { ApiErrorResponse, ApiMeta, ApiSuccess, ErrorCode } from "@/types";
3
+
4
+ type SuccessInit = {
5
+ status?: number;
6
+ requestId?: string;
7
+ meta?: Omit<ApiMeta, "timestamp" | "requestId">;
8
+ };
9
+
10
+ type FailInit = {
11
+ status?: number;
12
+ requestId?: string;
13
+ details?: unknown;
14
+ };
15
+
16
+ function createTimestamp(): string {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ export function ok<T>(data: T, init: SuccessInit = {}) {
21
+ const payload: ApiSuccess<T> = {
22
+ success: true,
23
+ data,
24
+ meta: {
25
+ timestamp: createTimestamp(),
26
+ requestId: init.requestId,
27
+ ...init.meta,
28
+ },
29
+ };
30
+ return NextResponse.json(payload, { status: init.status ?? 200 });
31
+ }
32
+
33
+ export function fail(code: ErrorCode, message: string, init: FailInit = {}) {
34
+ const payload: ApiErrorResponse = {
35
+ success: false,
36
+ error: {
37
+ code,
38
+ message,
39
+ details: init.details,
40
+ },
41
+ timestamp: createTimestamp(),
42
+ requestId: init.requestId,
43
+ };
44
+ return NextResponse.json(payload, { status: init.status ?? 500 });
45
+ }
@@ -0,0 +1,13 @@
1
+ let accessToken: string | null = null;
2
+
3
+ export function getAccessToken(): string | null {
4
+ return accessToken;
5
+ }
6
+
7
+ export function setAccessToken(token: string | null): void {
8
+ accessToken = token;
9
+ }
10
+
11
+ export function clearAccessToken(): void {
12
+ accessToken = null;
13
+ }
@@ -0,0 +1,95 @@
1
+ import type { PrismaClient } from "@prisma/client";
2
+ import {
3
+ ADMIN_ROLE_LEVEL,
4
+ ADMIN_ROLE_NAME,
5
+ DEFAULT_ADMIN_PASSWORD,
6
+ INTERNAL_EMAIL_DOMAIN,
7
+ SUPER_ADMIN_USERNAME,
8
+ } from "@/lib/constants";
9
+ import { auth } from "@/lib/auth";
10
+ import prisma from "@/lib/db/prisma";
11
+
12
+ function hasRoleModel(client: PrismaClient): boolean {
13
+ const delegate = (
14
+ client as PrismaClient & { role?: { findUnique?: unknown } }
15
+ ).role;
16
+ return typeof delegate?.findUnique === "function";
17
+ }
18
+
19
+ function logBootstrapSkip(message: string): void {
20
+ console.warn(`[bootstrap] ${message}`);
21
+ }
22
+
23
+ export async function runBootstrap(): Promise<void> {
24
+ if (!hasRoleModel(prisma)) {
25
+ logBootstrapSkip(
26
+ "Prisma client is missing the Role model. Run: bun run db:generate && bun run db:migrate",
27
+ );
28
+ return;
29
+ }
30
+
31
+ try {
32
+ let adminRole = await prisma.role.findUnique({
33
+ where: { name: ADMIN_ROLE_NAME },
34
+ });
35
+
36
+ if (!adminRole) {
37
+ adminRole = await prisma.role.create({
38
+ data: {
39
+ name: ADMIN_ROLE_NAME,
40
+ level: ADMIN_ROLE_LEVEL,
41
+ },
42
+ });
43
+ }
44
+
45
+ const existingAdmin = await prisma.user.findUnique({
46
+ where: { username: SUPER_ADMIN_USERNAME },
47
+ });
48
+
49
+ if (existingAdmin) {
50
+ if (!existingAdmin.roleId) {
51
+ await prisma.user.update({
52
+ where: { id: existingAdmin.id },
53
+ data: { roleId: adminRole.id },
54
+ });
55
+ }
56
+ return;
57
+ }
58
+
59
+ try {
60
+ await auth.api.signUpEmail({
61
+ body: {
62
+ email: `${SUPER_ADMIN_USERNAME}@${INTERNAL_EMAIL_DOMAIN}`,
63
+ password: DEFAULT_ADMIN_PASSWORD,
64
+ name: "Administrator",
65
+ username: SUPER_ADMIN_USERNAME,
66
+ displayUsername: SUPER_ADMIN_USERNAME,
67
+ },
68
+ });
69
+ } catch {
70
+ // User may already exist from a partial bootstrap run.
71
+ }
72
+
73
+ const adminUser = await prisma.user.findUnique({
74
+ where: { username: SUPER_ADMIN_USERNAME },
75
+ });
76
+
77
+ if (!adminUser) {
78
+ return;
79
+ }
80
+
81
+ await prisma.user.update({
82
+ where: { id: adminUser.id },
83
+ data: {
84
+ roleId: adminRole.id,
85
+ requirePasswordChange: true,
86
+ },
87
+ });
88
+ } catch (error) {
89
+ const message =
90
+ error instanceof Error ? error.message : "Unknown bootstrap error";
91
+ logBootstrapSkip(
92
+ `Skipped admin seed (${message}). Ensure Postgres is running and run: bun run db:migrate`,
93
+ );
94
+ }
95
+ }
@@ -0,0 +1,7 @@
1
+ import { createAuthClient } from "better-auth/react";
2
+ import { jwtClient, usernameClient } from "better-auth/client/plugins";
3
+
4
+ export const authClient = createAuthClient({
5
+ baseURL: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
6
+ plugins: [jwtClient(), usernameClient()],
7
+ });
@@ -0,0 +1,15 @@
1
+ const refreshCookieName = "nextcli_refresh_token";
2
+
3
+ export function refreshCookieOptions() {
4
+ return {
5
+ httpOnly: true,
6
+ secure: process.env.NODE_ENV === "production",
7
+ sameSite: "lax",
8
+ path: "/",
9
+ maxAge: 60 * 60 * 24 * 30,
10
+ } as const;
11
+ }
12
+
13
+ export function getRefreshCookieName(): string {
14
+ return refreshCookieName;
15
+ }
@@ -0,0 +1 @@
1
+ export { auth } from "./server";
@@ -0,0 +1,59 @@
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/db/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(actor: SessionUser, target: SessionUser): boolean {
31
+ if (isSuperAdmin(target)) {
32
+ return false;
33
+ }
34
+
35
+ if (!actor.role || !target.role) {
36
+ return false;
37
+ }
38
+
39
+ return actor.role.level > target.role.level;
40
+ }
41
+
42
+ export function canAssignRole(
43
+ actor: SessionUser,
44
+ targetRole: Pick<Role, "level">,
45
+ ): boolean {
46
+ if (!actor.role) {
47
+ return false;
48
+ }
49
+
50
+ return actor.role.level > targetRole.level;
51
+ }
52
+
53
+ export function requireAuthenticated(
54
+ user: SessionUser | null,
55
+ ): asserts user is SessionUser {
56
+ if (!user) {
57
+ throw new Error("UNAUTHORIZED");
58
+ }
59
+ }
@@ -0,0 +1,21 @@
1
+ import { betterAuth } from "better-auth/minimal";
2
+ import { prismaAdapter } from "better-auth/adapters/prisma";
3
+ import { jwt, username } from "better-auth/plugins";
4
+ import prisma from "@/lib/db/prisma";
5
+
6
+ const socialProviders = {
7
+ // AUTO_GENERATED_AUTH_PROVIDERS_START
8
+ // AUTO_GENERATED_AUTH_PROVIDERS_END
9
+ };
10
+
11
+ export const auth = betterAuth({
12
+ database: prismaAdapter(prisma, {
13
+ provider: "postgresql",
14
+ }),
15
+ plugins: [jwt(), username()],
16
+ emailAndPassword: {
17
+ enabled: true,
18
+ minPasswordLength: 8,
19
+ },
20
+ socialProviders,
21
+ });
@@ -0,0 +1,10 @@
1
+ /** Built-in super-admin account username — protected from RBAC mutations. */
2
+ export const SUPER_ADMIN_USERNAME = "admin";
3
+
4
+ /** Dev-only bootstrap password (≥8 chars for Better Auth). Change on first login. */
5
+ export const DEFAULT_ADMIN_PASSWORD = "admin1234";
6
+
7
+ export const ADMIN_ROLE_NAME = "admin";
8
+ export const ADMIN_ROLE_LEVEL = 100;
9
+
10
+ export const INTERNAL_EMAIL_DOMAIN = "internal.local";