@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.
- package/README.md +6 -2
- package/dist/cli.js +778 -101
- 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/components.json +21 -0
- package/templates/next-base/messages/vi/auth.json +42 -0
- package/templates/next-base/messages/vi/common.json +34 -0
- package/templates/next-base/messages/vi/example.json +10 -0
- package/templates/next-base/next-env.d.ts +3 -1
- package/templates/next-base/next.config.ts +11 -1
- package/templates/next-base/nextcli.json +8 -0
- package/templates/next-base/package.json +21 -1
- package/templates/next-base/postcss.config.mjs +5 -0
- 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 +9 -0
- package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
- package/templates/next-base/src/app/(auth)/sign-in/page.tsx +6 -3
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +9 -5
- package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +5 -2
- package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
- 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 +111 -0
- package/templates/next-base/src/app/layout.tsx +24 -10
- package/templates/next-base/src/app/page.tsx +2 -18
- package/templates/next-base/src/components/branding/logo.tsx +27 -0
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
- package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
- package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
- package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
- package/templates/next-base/src/components/providers/theme-provider.tsx +11 -0
- package/templates/next-base/src/components/ui/alert-dialog.tsx +11 -0
- package/templates/next-base/src/components/ui/avatar.tsx +45 -0
- package/templates/next-base/src/components/ui/badge.tsx +29 -0
- package/templates/next-base/src/components/ui/button.tsx +47 -7
- package/templates/next-base/src/components/ui/card.tsx +54 -0
- package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
- package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
- package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
- package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
- package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
- package/templates/next-base/src/components/ui/dialog.tsx +105 -0
- package/templates/next-base/src/components/ui/dropdown-menu.tsx +44 -0
- package/templates/next-base/src/components/ui/input.tsx +19 -0
- package/templates/next-base/src/components/ui/label.tsx +15 -0
- package/templates/next-base/src/components/ui/popover.tsx +30 -0
- package/templates/next-base/src/components/ui/scroll-area.tsx +47 -0
- package/templates/next-base/src/components/ui/select.tsx +76 -0
- package/templates/next-base/src/components/ui/separator.tsx +23 -0
- package/templates/next-base/src/components/ui/sheet.tsx +117 -0
- package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
- package/templates/next-base/src/components/ui/skeleton.tsx +10 -0
- package/templates/next-base/src/components/ui/sonner.tsx +3 -0
- package/templates/next-base/src/components/ui/table.tsx +54 -0
- package/templates/next-base/src/components/ui/tabs.tsx +52 -0
- package/templates/next-base/src/components/ui/textarea.tsx +17 -0
- package/templates/next-base/src/components/ui/tooltip.tsx +26 -0
- package/templates/next-base/src/config/branding.ts +14 -0
- package/templates/next-base/src/data/sidebar-modules.ts +11 -0
- package/templates/next-base/src/example/components/example-table.tsx +25 -40
- package/templates/next-base/src/features/auth/components/account-panel.tsx +32 -14
- 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 +53 -35
- 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/hooks/index.ts +1 -1
- package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
- package/templates/next-base/src/hooks/use-mobile.ts +25 -0
- package/templates/next-base/src/i18n/config.ts +7 -0
- package/templates/next-base/src/i18n/namespaces.ts +5 -0
- package/templates/next-base/src/i18n/request.ts +19 -2
- 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/prisma.ts +11 -1
- package/templates/next-base/src/lib/rbac.ts +62 -0
- package/templates/next-base/src/types/data-table.ts +4 -0
- package/templates/next-base/src/types/index.ts +2 -0
- package/templates/next-base/tsconfig.json +29 -7
- package/templates/next-base/middleware.ts +0 -10
- 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 {
|
|
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>
|
|
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 {
|
|
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
|
|
9
|
+
type MePayload = {
|
|
8
10
|
user?: {
|
|
9
11
|
id: string;
|
|
10
|
-
|
|
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
|
|
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
|
|
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<
|
|
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>
|
|
52
|
+
return <p>{t("loading")}</p>;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
if (!session?.user) {
|
|
51
|
-
return <p>
|
|
56
|
+
return <p>{t("noSession")}</p>;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
return (
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
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
|
|
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({
|
|
32
|
+
const parsed = signInSchema.safeParse({ username, password });
|
|
22
33
|
if (!parsed.success) {
|
|
23
|
-
toast.error("
|
|
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
|
|
33
|
-
if (!accessToken) {
|
|
34
|
-
toast.error("
|
|
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("
|
|
40
|
-
router.push("/
|
|
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 : "
|
|
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
|
-
<
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
6
|
-
messages:
|
|
22
|
+
locale,
|
|
23
|
+
messages: Object.fromEntries(namespaceMessages),
|
|
7
24
|
};
|
|
8
25
|
});
|