@thinhnguyencth1204/nextcli 0.8.0 → 1.0.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 +27 -24
- package/dist/cli.js +168 -107
- package/package.json +1 -1
- package/templates/features/api/src/lib/api/axios.ts +1 -90
- package/templates/features/auth/messages/vi/auth.json +2 -1
- package/templates/features/auth/src/app/(auth)/change-password/page.tsx +5 -4
- package/templates/features/auth/src/app/(auth)/layout.tsx +2 -5
- package/templates/features/auth/src/app/(auth)/sign-in/page.tsx +5 -4
- package/templates/features/auth/src/app/api/v1/auth/login/route.ts +24 -29
- package/templates/features/auth/src/app/api/v1/auth/logout/route.ts +0 -5
- package/templates/features/auth/src/components/layout/auth/auth-shell.tsx +24 -0
- package/templates/features/auth/src/features/auth/components/account-panel.tsx +15 -3
- package/templates/features/auth/src/features/auth/components/change-password-form.tsx +27 -30
- package/templates/features/auth/src/features/auth/components/sign-in-form.tsx +33 -42
- package/templates/features/auth/src/lib/auth/client.ts +2 -2
- package/templates/features/auth/src/lib/auth/server.ts +2 -2
- package/templates/features/dashboard/src/app/(dashboard)/account/page.tsx +9 -7
- package/templates/features/dashboard/src/app/(dashboard)/dashboard/page.tsx +24 -10
- package/templates/features/dashboard/src/components/layout/private/app-sidebar.tsx +1 -13
- package/templates/features/dashboard/src/components/layout/private/dashboard-layout.tsx +31 -22
- package/templates/features/dashboard/src/components/layout/private/page-shell.tsx +40 -0
- package/templates/features/database/prisma/schema.prisma +1 -0
- package/templates/features/example/messages/vi/example.json +11 -1
- package/templates/features/example/src/app/(dashboard)/example/page.tsx +92 -3
- package/templates/features/example/src/example/components/example-table.tsx +15 -2
- package/templates/next-base/.env +16 -0
- package/templates/next-base/.env.development +16 -0
- package/templates/next-base/.env.example +16 -0
- package/templates/next-base/SETUP.md +62 -10
- package/templates/next-base/bun.lock +407 -0
- package/templates/next-base/messages/vi/auth.json +43 -0
- package/templates/next-base/messages/vi/common.json +53 -0
- package/templates/next-base/messages/vi/example.json +20 -0
- package/templates/next-base/next-env.d.ts +1 -1
- package/templates/next-base/next.config.ts +4 -1
- package/templates/next-base/nextcli.json +12 -4
- package/templates/next-base/package.json +24 -5
- package/templates/next-base/prisma/schema.prisma +85 -0
- package/templates/next-base/prisma.config.ts +16 -0
- package/templates/next-base/src/app/(auth)/.gitkeep +1 -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 +15 -0
- package/templates/next-base/src/app/(auth)/layout.tsx +6 -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 +15 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +20 -0
- package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +31 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +102 -0
- package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -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 +65 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +23 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- 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/layout.tsx +14 -6
- package/templates/next-base/src/app/page.tsx +2 -25
- package/templates/next-base/src/components/branding/logo.tsx +27 -4
- package/templates/next-base/src/components/layout/auth/auth-shell.tsx +24 -0
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +32 -0
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +63 -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/layout/private/page-shell.tsx +40 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -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/sidebar.tsx +215 -0
- package/templates/next-base/src/data/sidebar-modules.ts +11 -0
- package/templates/next-base/src/example/api/use-example.ts +21 -0
- package/templates/next-base/src/example/api/use-mutations.ts +20 -0
- package/templates/next-base/src/example/components/example-table.tsx +64 -0
- package/templates/next-base/src/example/services.ts +9 -0
- package/templates/next-base/src/example/validations.ts +8 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +92 -0
- package/templates/next-base/src/features/auth/components/change-password-form.tsx +79 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +86 -0
- package/templates/next-base/src/features/auth/validations.ts +14 -0
- 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/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 +25 -0
- package/templates/next-base/src/instrumentation.ts +14 -0
- package/templates/next-base/src/lib/api/axios.ts +56 -0
- package/templates/next-base/src/lib/api/response.ts +45 -0
- package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
- package/templates/next-base/src/lib/auth/client.ts +7 -0
- package/templates/next-base/src/lib/auth/index.ts +1 -0
- package/templates/next-base/src/lib/auth/rbac.ts +59 -0
- package/templates/next-base/src/lib/auth/server.ts +21 -0
- package/templates/next-base/src/lib/constants.ts +10 -0
- package/templates/next-base/src/lib/db/prisma.ts +23 -0
- package/templates/next-base/src/lib/prisma.ts +23 -0
- package/templates/next-base/src/lib/supabase/client.ts +6 -0
- package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
- package/templates/next-base/src/lib/supabase/storage.ts +164 -0
- package/templates/next-base/src/types/data-table.ts +4 -0
- package/templates/features/api/src/lib/api/token-store.ts +0 -13
- package/templates/features/auth/src/app/api/v1/auth/refresh/route.ts +0 -32
- package/templates/features/auth/src/lib/auth/cookies.ts +0 -15
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { protectedApi } from "@/lib/api/axios";
|
|
6
|
+
import type { ApiSuccess } from "@/types";
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
|
|
9
|
+
type MePayload = {
|
|
10
|
+
user?: {
|
|
11
|
+
id: string;
|
|
12
|
+
username: string;
|
|
13
|
+
email?: string | null;
|
|
14
|
+
name?: string | null;
|
|
15
|
+
role?: { name: string; level: number } | null;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function AccountPanel() {
|
|
20
|
+
const t = useTranslations("auth.account");
|
|
21
|
+
const [session, setSession] = useState<MePayload | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let mounted = true;
|
|
26
|
+
const run = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await protectedApi.get("/api/v1/auth/me", {
|
|
29
|
+
withCredentials: true,
|
|
30
|
+
});
|
|
31
|
+
if (mounted) {
|
|
32
|
+
setSession((response.data as ApiSuccess<MePayload>).data);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
if (mounted) {
|
|
36
|
+
setSession(null);
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
if (mounted) {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
void run();
|
|
46
|
+
return () => {
|
|
47
|
+
mounted = false;
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
if (loading) {
|
|
52
|
+
return (
|
|
53
|
+
<Card className="max-w-xl">
|
|
54
|
+
<CardContent className="py-8 text-sm text-muted-foreground">
|
|
55
|
+
{t("loading")}
|
|
56
|
+
</CardContent>
|
|
57
|
+
</Card>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!session?.user) {
|
|
62
|
+
return (
|
|
63
|
+
<Card className="max-w-xl">
|
|
64
|
+
<CardContent className="py-8 text-sm text-muted-foreground">
|
|
65
|
+
{t("noSession")}
|
|
66
|
+
</CardContent>
|
|
67
|
+
</Card>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Card className="max-w-xl">
|
|
73
|
+
<CardHeader>
|
|
74
|
+
<CardTitle>{t("panelTitle")}</CardTitle>
|
|
75
|
+
</CardHeader>
|
|
76
|
+
<CardContent className="space-y-2 text-sm">
|
|
77
|
+
<p>
|
|
78
|
+
{t("userId")}: {session.user.id}
|
|
79
|
+
</p>
|
|
80
|
+
<p>
|
|
81
|
+
{t("username")}: {session.user.username}
|
|
82
|
+
</p>
|
|
83
|
+
<p>
|
|
84
|
+
{t("email")}: {session.user.email ?? t("na")}
|
|
85
|
+
</p>
|
|
86
|
+
<p>
|
|
87
|
+
{t("name")}: {session.user.name ?? t("na")}
|
|
88
|
+
</p>
|
|
89
|
+
</CardContent>
|
|
90
|
+
</Card>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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/api/axios";
|
|
9
|
+
import { changePasswordSchema } from "@/features/auth/validations";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import { Input } from "@/components/ui/input";
|
|
12
|
+
import { Label } from "@/components/ui/label";
|
|
13
|
+
|
|
14
|
+
export function ChangePasswordForm() {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const t = useTranslations("auth.changePasswordForm");
|
|
17
|
+
const [currentPassword, setCurrentPassword] = useState("");
|
|
18
|
+
const [newPassword, setNewPassword] = useState("");
|
|
19
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
20
|
+
|
|
21
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
|
|
24
|
+
const parsed = changePasswordSchema.safeParse({
|
|
25
|
+
currentPassword,
|
|
26
|
+
newPassword,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!parsed.success) {
|
|
30
|
+
toast.error(t("invalidInput"));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
setIsSubmitting(true);
|
|
36
|
+
await protectedApi.post("/api/v1/auth/change-password", parsed.data, {
|
|
37
|
+
withCredentials: true,
|
|
38
|
+
});
|
|
39
|
+
toast.success(t("success"));
|
|
40
|
+
router.push("/dashboard");
|
|
41
|
+
router.refresh();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : t("failed");
|
|
44
|
+
toast.error(message);
|
|
45
|
+
} finally {
|
|
46
|
+
setIsSubmitting(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<form onSubmit={onSubmit} className="space-y-6">
|
|
52
|
+
<div className="space-y-2">
|
|
53
|
+
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
|
|
54
|
+
<Input
|
|
55
|
+
id="currentPassword"
|
|
56
|
+
type="password"
|
|
57
|
+
value={currentPassword}
|
|
58
|
+
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
59
|
+
className="h-12"
|
|
60
|
+
required
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
<Label htmlFor="newPassword">{t("newPassword")}</Label>
|
|
65
|
+
<Input
|
|
66
|
+
id="newPassword"
|
|
67
|
+
type="password"
|
|
68
|
+
value={newPassword}
|
|
69
|
+
onChange={(event) => setNewPassword(event.target.value)}
|
|
70
|
+
className="h-12"
|
|
71
|
+
required
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
<Button type="submit" className="h-12 w-full" disabled={isSubmitting}>
|
|
75
|
+
{isSubmitting ? t("submitting") : t("submit")}
|
|
76
|
+
</Button>
|
|
77
|
+
</form>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { publicApi } from "@/lib/api/axios";
|
|
9
|
+
import { signInSchema } from "@/features/auth/validations";
|
|
10
|
+
import type { ApiSuccess } from "@/types";
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Label } from "@/components/ui/label";
|
|
14
|
+
|
|
15
|
+
type LoginResponse = {
|
|
16
|
+
requirePasswordChange: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function SignInForm() {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const t = useTranslations("auth.signInForm");
|
|
22
|
+
const [username, setUsername] = useState("");
|
|
23
|
+
const [password, setPassword] = useState("");
|
|
24
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
25
|
+
|
|
26
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
|
|
29
|
+
const parsed = signInSchema.safeParse({ username, password });
|
|
30
|
+
if (!parsed.success) {
|
|
31
|
+
toast.error(t("invalidInput"));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
setIsSubmitting(true);
|
|
37
|
+
const response = await publicApi.post("/api/v1/auth/login", parsed.data, {
|
|
38
|
+
withCredentials: true,
|
|
39
|
+
});
|
|
40
|
+
const payload = (response.data as ApiSuccess<LoginResponse>).data;
|
|
41
|
+
toast.success(t("success"));
|
|
42
|
+
router.push(
|
|
43
|
+
payload.requirePasswordChange ? "/change-password" : "/dashboard",
|
|
44
|
+
);
|
|
45
|
+
router.refresh();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const message = error instanceof Error ? error.message : t("failed");
|
|
48
|
+
toast.error(message);
|
|
49
|
+
} finally {
|
|
50
|
+
setIsSubmitting(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<form onSubmit={onSubmit} className="space-y-6">
|
|
56
|
+
<div className="space-y-2">
|
|
57
|
+
<Label htmlFor="username">{t("username")}</Label>
|
|
58
|
+
<Input
|
|
59
|
+
id="username"
|
|
60
|
+
value={username}
|
|
61
|
+
onChange={(event) => setUsername(event.target.value)}
|
|
62
|
+
placeholder={t("usernamePlaceholder")}
|
|
63
|
+
autoComplete="username"
|
|
64
|
+
className="h-12"
|
|
65
|
+
required
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="space-y-2">
|
|
69
|
+
<Label htmlFor="password">{t("password")}</Label>
|
|
70
|
+
<Input
|
|
71
|
+
id="password"
|
|
72
|
+
type="password"
|
|
73
|
+
value={password}
|
|
74
|
+
onChange={(event) => setPassword(event.target.value)}
|
|
75
|
+
placeholder={t("passwordPlaceholder")}
|
|
76
|
+
autoComplete="current-password"
|
|
77
|
+
className="h-12"
|
|
78
|
+
required
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<Button type="submit" className="h-12 w-full" disabled={isSubmitting}>
|
|
82
|
+
{isSubmitting ? t("submitting") : t("submit")}
|
|
83
|
+
</Button>
|
|
84
|
+
</form>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const signInSchema = z.object({
|
|
4
|
+
username: z.string().min(3).max(30),
|
|
5
|
+
password: z.string().min(8),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const changePasswordSchema = z.object({
|
|
9
|
+
currentPassword: z.string().min(8),
|
|
10
|
+
newPassword: z.string().min(8),
|
|
11
|
+
});
|
|
12
|
+
|
|
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/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>;
|
|
@@ -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,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,56 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import type { ApiErrorResponse } from "@/types";
|
|
3
|
+
|
|
4
|
+
const baseURL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
|
5
|
+
|
|
6
|
+
export const publicApi = axios.create({
|
|
7
|
+
baseURL,
|
|
8
|
+
withCredentials: true,
|
|
9
|
+
headers: {
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const protectedApi = axios.create({
|
|
15
|
+
baseURL,
|
|
16
|
+
withCredentials: true,
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function extractApiErrorMessage(error: unknown): string {
|
|
23
|
+
if (!axios.isAxiosError(error)) {
|
|
24
|
+
return "Unexpected error occurred.";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const payload = error.response?.data as ApiErrorResponse | undefined;
|
|
28
|
+
if (payload && payload.success === false) {
|
|
29
|
+
return payload.error.message;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return error.message || "Request failed.";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
publicApi.interceptors.response.use(
|
|
36
|
+
(response) => response,
|
|
37
|
+
async (error) => {
|
|
38
|
+
if (axios.isAxiosError(error)) {
|
|
39
|
+
error.message = extractApiErrorMessage(error);
|
|
40
|
+
}
|
|
41
|
+
return Promise.reject(error);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
protectedApi.interceptors.response.use(
|
|
46
|
+
(response) => response,
|
|
47
|
+
async (error) => {
|
|
48
|
+
if (axios.isAxiosError(error)) {
|
|
49
|
+
error.message = extractApiErrorMessage(error);
|
|
50
|
+
}
|
|
51
|
+
return Promise.reject(error);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
export const api = publicApi;
|
|
56
|
+
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
|
+
}
|