@thinhnguyencth1204/nextcli 0.2.1 → 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.
Files changed (98) hide show
  1. package/README.md +6 -2
  2. package/dist/cli.js +778 -101
  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/components.json +21 -0
  8. package/templates/next-base/messages/vi/auth.json +42 -0
  9. package/templates/next-base/messages/vi/common.json +34 -0
  10. package/templates/next-base/messages/vi/example.json +10 -0
  11. package/templates/next-base/next-env.d.ts +3 -1
  12. package/templates/next-base/next.config.ts +11 -1
  13. package/templates/next-base/nextcli.json +8 -0
  14. package/templates/next-base/package.json +21 -1
  15. package/templates/next-base/postcss.config.mjs +5 -0
  16. package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +104 -0
  17. package/templates/next-base/prisma/migrations/migration_lock.toml +3 -0
  18. package/templates/next-base/prisma/schema.prisma +23 -9
  19. package/templates/next-base/public/logo.svg +4 -0
  20. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  21. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  22. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  23. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +6 -3
  25. package/templates/next-base/src/app/(dashboard)/account/page.tsx +9 -5
  26. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  27. package/templates/next-base/src/app/(dashboard)/example/page.tsx +5 -2
  28. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  29. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  30. package/templates/next-base/src/app/api/v1/auth/login/route.ts +15 -5
  31. package/templates/next-base/src/app/api/v1/auth/me/route.ts +17 -19
  32. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  33. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  34. package/templates/next-base/src/app/globals.css +111 -0
  35. package/templates/next-base/src/app/layout.tsx +24 -10
  36. package/templates/next-base/src/app/page.tsx +2 -18
  37. package/templates/next-base/src/components/branding/logo.tsx +27 -0
  38. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  39. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  40. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  41. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  42. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  43. package/templates/next-base/src/components/providers/theme-provider.tsx +11 -0
  44. package/templates/next-base/src/components/ui/alert-dialog.tsx +11 -0
  45. package/templates/next-base/src/components/ui/avatar.tsx +45 -0
  46. package/templates/next-base/src/components/ui/badge.tsx +29 -0
  47. package/templates/next-base/src/components/ui/button.tsx +47 -7
  48. package/templates/next-base/src/components/ui/card.tsx +54 -0
  49. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  50. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  51. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  52. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  53. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  54. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  55. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  56. package/templates/next-base/src/components/ui/dialog.tsx +105 -0
  57. package/templates/next-base/src/components/ui/dropdown-menu.tsx +44 -0
  58. package/templates/next-base/src/components/ui/input.tsx +19 -0
  59. package/templates/next-base/src/components/ui/label.tsx +15 -0
  60. package/templates/next-base/src/components/ui/popover.tsx +30 -0
  61. package/templates/next-base/src/components/ui/scroll-area.tsx +47 -0
  62. package/templates/next-base/src/components/ui/select.tsx +76 -0
  63. package/templates/next-base/src/components/ui/separator.tsx +23 -0
  64. package/templates/next-base/src/components/ui/sheet.tsx +117 -0
  65. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  66. package/templates/next-base/src/components/ui/skeleton.tsx +10 -0
  67. package/templates/next-base/src/components/ui/sonner.tsx +3 -0
  68. package/templates/next-base/src/components/ui/table.tsx +54 -0
  69. package/templates/next-base/src/components/ui/tabs.tsx +52 -0
  70. package/templates/next-base/src/components/ui/textarea.tsx +17 -0
  71. package/templates/next-base/src/components/ui/tooltip.tsx +26 -0
  72. package/templates/next-base/src/config/branding.ts +14 -0
  73. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  74. package/templates/next-base/src/example/components/example-table.tsx +25 -40
  75. package/templates/next-base/src/features/auth/components/account-panel.tsx +32 -14
  76. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  77. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +53 -35
  78. package/templates/next-base/src/features/auth/validations.ts +7 -1
  79. package/templates/next-base/src/features/users/services.ts +132 -0
  80. package/templates/next-base/src/features/users/validations.ts +21 -0
  81. package/templates/next-base/src/hooks/index.ts +1 -1
  82. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  83. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  84. package/templates/next-base/src/i18n/config.ts +7 -0
  85. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  86. package/templates/next-base/src/i18n/request.ts +19 -2
  87. package/templates/next-base/src/instrumentation.ts +14 -0
  88. package/templates/next-base/src/lib/auth-client.ts +2 -2
  89. package/templates/next-base/src/lib/auth.ts +2 -2
  90. package/templates/next-base/src/lib/bootstrap.ts +96 -0
  91. package/templates/next-base/src/lib/constants.ts +7 -0
  92. package/templates/next-base/src/lib/prisma.ts +11 -1
  93. package/templates/next-base/src/lib/rbac.ts +62 -0
  94. package/templates/next-base/src/types/data-table.ts +4 -0
  95. package/templates/next-base/src/types/index.ts +2 -0
  96. package/templates/next-base/tsconfig.json +29 -7
  97. package/templates/next-base/middleware.ts +0 -10
  98. package/templates/next-base/src/app/styles.css +0 -12
@@ -0,0 +1,11 @@
1
+ export type SidebarModule = {
2
+ id: "dashboard" | "example" | "account";
3
+ url: string;
4
+ icon: "layout-dashboard" | "table" | "user";
5
+ };
6
+
7
+ export const sidebarModules: SidebarModule[] = [
8
+ { id: "dashboard", url: "/dashboard", icon: "layout-dashboard" },
9
+ { id: "example", url: "/example", icon: "table" },
10
+ { id: "account", url: "/account", icon: "user" },
11
+ ];
@@ -1,8 +1,15 @@
1
1
  "use client";
2
2
 
3
3
  import { useMemo } from "react";
4
- import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
4
+ import { useTranslations } from "next-intl";
5
+ import {
6
+ createColumnHelper,
7
+ getCoreRowModel,
8
+ getPaginationRowModel,
9
+ useReactTable,
10
+ } from "@tanstack/react-table";
5
11
  import { useExample } from "@/example/api/use-example";
12
+ import { DataTable } from "@/components/ui/data-table/data-table";
6
13
 
7
14
  type ExampleItem = {
8
15
  id: string;
@@ -12,55 +19,33 @@ type ExampleItem = {
12
19
 
13
20
  const columnHelper = createColumnHelper<ExampleItem>();
14
21
 
15
- const columns = [
16
- columnHelper.accessor("name", {
17
- header: "Name",
18
- }),
19
- columnHelper.accessor("description", {
20
- header: "Description",
21
- }),
22
- ];
23
-
24
22
  export function ExampleTable() {
23
+ const t = useTranslations("example.table");
25
24
  const { data, isLoading } = useExample();
26
25
  const rows = useMemo(() => (Array.isArray(data) ? data : []), [data]);
27
26
 
27
+ const translatedColumns = useMemo(
28
+ () => [
29
+ columnHelper.accessor("name", {
30
+ header: t("name"),
31
+ }),
32
+ columnHelper.accessor("description", {
33
+ header: t("description"),
34
+ }),
35
+ ],
36
+ [t],
37
+ );
38
+
28
39
  const table = useReactTable({
29
40
  data: rows,
30
- columns,
41
+ columns: translatedColumns,
31
42
  getCoreRowModel: getCoreRowModel(),
43
+ getPaginationRowModel: getPaginationRowModel(),
32
44
  });
33
45
 
34
46
  if (isLoading) {
35
- return <p>Loading examples...</p>;
47
+ return <p>{t("loading")}</p>;
36
48
  }
37
49
 
38
- return (
39
- <table style={{ width: "100%", borderCollapse: "collapse" }}>
40
- <thead>
41
- {table.getHeaderGroups().map((headerGroup) => (
42
- <tr key={headerGroup.id}>
43
- {headerGroup.headers.map((header) => (
44
- <th key={header.id} style={{ textAlign: "left", borderBottom: "1px solid #ccc" }}>
45
- {typeof header.column.columnDef.header === "string"
46
- ? header.column.columnDef.header
47
- : null}
48
- </th>
49
- ))}
50
- </tr>
51
- ))}
52
- </thead>
53
- <tbody>
54
- {table.getRowModel().rows.map((row) => (
55
- <tr key={row.id}>
56
- {row.getVisibleCells().map((cell) => (
57
- <td key={cell.id} style={{ padding: "8px 0", borderBottom: "1px solid #eee" }}>
58
- {String(cell.getValue() ?? "")}
59
- </td>
60
- ))}
61
- </tr>
62
- ))}
63
- </tbody>
64
- </table>
65
- );
50
+ return <DataTable table={table} />;
66
51
  }
@@ -1,30 +1,35 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
- import { publicApi } from "@/lib/axios-instance";
4
+ import { useTranslations } from "next-intl";
5
+ import { protectedApi } from "@/lib/axios-instance";
5
6
  import type { ApiSuccess } from "@/types";
7
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
8
 
7
- type SessionPayload = {
9
+ type MePayload = {
8
10
  user?: {
9
11
  id: string;
10
- email: string;
12
+ username: string;
13
+ email?: string | null;
11
14
  name?: string | null;
15
+ role?: { name: string; level: number } | null;
12
16
  };
13
17
  };
14
18
 
15
19
  export function AccountPanel() {
16
- const [session, setSession] = useState<SessionPayload | null>(null);
20
+ const t = useTranslations("auth.account");
21
+ const [session, setSession] = useState<MePayload | null>(null);
17
22
  const [loading, setLoading] = useState(true);
18
23
 
19
24
  useEffect(() => {
20
25
  let mounted = true;
21
26
  const run = async () => {
22
27
  try {
23
- const response = await publicApi.get("/api/v1/auth/me", {
28
+ const response = await protectedApi.get("/api/v1/auth/me", {
24
29
  withCredentials: true,
25
30
  });
26
31
  if (mounted) {
27
- setSession((response.data as ApiSuccess<SessionPayload>).data);
32
+ setSession((response.data as ApiSuccess<MePayload>).data);
28
33
  }
29
34
  } catch {
30
35
  if (mounted) {
@@ -44,19 +49,32 @@ export function AccountPanel() {
44
49
  }, []);
45
50
 
46
51
  if (loading) {
47
- return <p>Loading account...</p>;
52
+ return <p>{t("loading")}</p>;
48
53
  }
49
54
 
50
55
  if (!session?.user) {
51
- return <p>No active session. Please sign in first.</p>;
56
+ return <p>{t("noSession")}</p>;
52
57
  }
53
58
 
54
59
  return (
55
- <section style={{ border: "1px solid #ddd", padding: 16, borderRadius: 8, maxWidth: 420 }}>
56
- <h2>My account</h2>
57
- <p>User ID: {session.user.id}</p>
58
- <p>Email: {session.user.email}</p>
59
- <p>Name: {session.user.name ?? "N/A"}</p>
60
- </section>
60
+ <Card className="max-w-xl">
61
+ <CardHeader>
62
+ <CardTitle>{t("title")}</CardTitle>
63
+ </CardHeader>
64
+ <CardContent className="space-y-2 text-sm">
65
+ <p>
66
+ {t("userId")}: {session.user.id}
67
+ </p>
68
+ <p>
69
+ {t("username")}: {session.user.username}
70
+ </p>
71
+ <p>
72
+ {t("email")}: {session.user.email ?? t("na")}
73
+ </p>
74
+ <p>
75
+ {t("name")}: {session.user.name ?? t("na")}
76
+ </p>
77
+ </CardContent>
78
+ </Card>
61
79
  );
62
80
  }
@@ -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
+ }
@@ -3,24 +3,35 @@
3
3
  import { useState } from "react";
4
4
  import type { FormEvent } from "react";
5
5
  import { useRouter } from "next/navigation";
6
+ import { useTranslations } from "next-intl";
6
7
  import { toast } from "sonner";
7
8
  import { publicApi } from "@/lib/axios-instance";
8
9
  import { setAccessToken } from "@/lib/token-store";
9
10
  import { signInSchema } from "@/features/auth/validations";
10
11
  import type { ApiSuccess } from "@/types";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Card, CardContent } from "@/components/ui/card";
14
+ import { Input } from "@/components/ui/input";
15
+ import { Label } from "@/components/ui/label";
16
+
17
+ type LoginResponse = {
18
+ accessToken: string;
19
+ requirePasswordChange: boolean;
20
+ };
11
21
 
12
22
  export function SignInForm() {
13
23
  const router = useRouter();
14
- const [email, setEmail] = useState("");
24
+ const t = useTranslations("auth.signInForm");
25
+ const [username, setUsername] = useState("");
15
26
  const [password, setPassword] = useState("");
16
27
  const [isSubmitting, setIsSubmitting] = useState(false);
17
28
 
18
29
  const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
19
30
  event.preventDefault();
20
31
 
21
- const parsed = signInSchema.safeParse({ email, password });
32
+ const parsed = signInSchema.safeParse({ username, password });
22
33
  if (!parsed.success) {
23
- toast.error("Please provide a valid email and password.");
34
+ toast.error(t("invalidInput"));
24
35
  return;
25
36
  }
26
37
 
@@ -29,18 +40,18 @@ export function SignInForm() {
29
40
  const response = await publicApi.post("/api/v1/auth/login", parsed.data, {
30
41
  withCredentials: true,
31
42
  });
32
- const accessToken = (response.data as ApiSuccess<{ accessToken: string }>).data?.accessToken;
33
- if (!accessToken) {
34
- toast.error("Access token is missing.");
43
+ const payload = (response.data as ApiSuccess<LoginResponse>).data;
44
+ if (!payload?.accessToken) {
45
+ toast.error(t("missingAccessToken"));
35
46
  return;
36
47
  }
37
48
 
38
- setAccessToken(accessToken);
39
- toast.success("Signed in successfully.");
40
- router.push("/account");
49
+ setAccessToken(payload.accessToken);
50
+ toast.success(t("success"));
51
+ router.push(payload.requirePasswordChange ? "/change-password" : "/dashboard");
41
52
  router.refresh();
42
53
  } catch (error) {
43
- const message = error instanceof Error ? error.message : "Sign in failed.";
54
+ const message = error instanceof Error ? error.message : t("failed");
44
55
  toast.error(message);
45
56
  } finally {
46
57
  setIsSubmitting(false);
@@ -48,30 +59,37 @@ export function SignInForm() {
48
59
  };
49
60
 
50
61
  return (
51
- <form onSubmit={onSubmit} style={{ display: "grid", gap: 12, maxWidth: 360 }}>
52
- <label style={{ display: "grid", gap: 4 }}>
53
- <span>Email</span>
54
- <input
55
- type="email"
56
- value={email}
57
- onChange={(event) => setEmail(event.target.value)}
58
- placeholder="you@example.com"
59
- required
60
- />
61
- </label>
62
- <label style={{ display: "grid", gap: 4 }}>
63
- <span>Password</span>
64
- <input
65
- type="password"
66
- value={password}
67
- onChange={(event) => setPassword(event.target.value)}
68
- placeholder="********"
69
- required
70
- />
71
- </label>
72
- <button type="submit" disabled={isSubmitting}>
73
- {isSubmitting ? "Signing in..." : "Sign in"}
74
- </button>
75
- </form>
62
+ <Card>
63
+ <CardContent className="pt-6">
64
+ <form onSubmit={onSubmit} className="grid gap-4">
65
+ <div className="grid gap-2">
66
+ <Label htmlFor="username">{t("username")}</Label>
67
+ <Input
68
+ id="username"
69
+ value={username}
70
+ onChange={(event) => setUsername(event.target.value)}
71
+ placeholder={t("usernamePlaceholder")}
72
+ autoComplete="username"
73
+ required
74
+ />
75
+ </div>
76
+ <div className="grid gap-2">
77
+ <Label htmlFor="password">{t("password")}</Label>
78
+ <Input
79
+ id="password"
80
+ type="password"
81
+ value={password}
82
+ onChange={(event) => setPassword(event.target.value)}
83
+ placeholder={t("passwordPlaceholder")}
84
+ autoComplete="current-password"
85
+ required
86
+ />
87
+ </div>
88
+ <Button type="submit" disabled={isSubmitting}>
89
+ {isSubmitting ? t("submitting") : t("submit")}
90
+ </Button>
91
+ </form>
92
+ </CardContent>
93
+ </Card>
76
94
  );
77
95
  }
@@ -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>;
@@ -1 +1 @@
1
- export {};
1
+ export * from "@/hooks/use-mobile";
@@ -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];
@@ -1,8 +1,25 @@
1
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";
2
5
 
3
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
+
4
21
  return {
5
- locale: "en",
6
- messages: {},
22
+ locale,
23
+ messages: Object.fromEntries(namespaceMessages),
7
24
  };
8
25
  });