@thinhnguyencth1204/nextcli 0.8.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 (85) hide show
  1. package/README.md +27 -24
  2. package/dist/cli.js +168 -107
  3. package/package.json +1 -1
  4. package/templates/next-base/.env +16 -0
  5. package/templates/next-base/.env.development +16 -0
  6. package/templates/next-base/.env.example +16 -0
  7. package/templates/next-base/SETUP.md +62 -10
  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.config.ts +4 -1
  12. package/templates/next-base/nextcli.json +12 -4
  13. package/templates/next-base/package.json +24 -5
  14. package/templates/next-base/prisma/schema.prisma +84 -0
  15. package/templates/next-base/prisma.config.ts +16 -0
  16. package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
  17. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  18. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  19. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  20. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  21. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
  22. package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
  23. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  24. package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
  25. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  26. package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  27. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  28. package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
  29. package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
  30. package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
  31. package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
  32. package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
  33. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  34. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  35. package/templates/next-base/src/app/layout.tsx +14 -6
  36. package/templates/next-base/src/app/page.tsx +2 -25
  37. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  38. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  39. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  40. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  41. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  42. package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
  43. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  44. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  45. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  46. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  47. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  48. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  49. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  50. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  51. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  52. package/templates/next-base/src/example/api/use-example.ts +21 -0
  53. package/templates/next-base/src/example/api/use-mutations.ts +20 -0
  54. package/templates/next-base/src/example/components/example-table.tsx +51 -0
  55. package/templates/next-base/src/example/services.ts +9 -0
  56. package/templates/next-base/src/example/validations.ts +8 -0
  57. package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
  58. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  59. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
  60. package/templates/next-base/src/features/auth/validations.ts +14 -0
  61. package/templates/next-base/src/features/users/services.ts +132 -0
  62. package/templates/next-base/src/features/users/validations.ts +21 -0
  63. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  64. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  65. package/templates/next-base/src/i18n/config.ts +7 -0
  66. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  67. package/templates/next-base/src/i18n/request.ts +25 -0
  68. package/templates/next-base/src/instrumentation.ts +14 -0
  69. package/templates/next-base/src/lib/api/axios.ts +145 -0
  70. package/templates/next-base/src/lib/api/response.ts +45 -0
  71. package/templates/next-base/src/lib/api/token-store.ts +13 -0
  72. package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
  73. package/templates/next-base/src/lib/auth/client.ts +7 -0
  74. package/templates/next-base/src/lib/auth/cookies.ts +15 -0
  75. package/templates/next-base/src/lib/auth/index.ts +1 -0
  76. package/templates/next-base/src/lib/auth/rbac.ts +59 -0
  77. package/templates/next-base/src/lib/auth/server.ts +21 -0
  78. package/templates/next-base/src/lib/constants.ts +10 -0
  79. package/templates/next-base/src/lib/db/prisma.ts +23 -0
  80. package/templates/next-base/src/lib/prisma.ts +23 -0
  81. package/templates/next-base/src/lib/supabase/client.ts +6 -0
  82. package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
  83. package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
  84. package/templates/next-base/src/lib/supabase/storage.ts +164 -0
  85. package/templates/next-base/src/types/data-table.ts +4 -0
@@ -0,0 +1,80 @@
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 <p>{t("loading")}</p>;
53
+ }
54
+
55
+ if (!session?.user) {
56
+ return <p>{t("noSession")}</p>;
57
+ }
58
+
59
+ return (
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>
79
+ );
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/api/axios";
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
+ }
@@ -0,0 +1,95 @@
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 { setAccessToken } from "@/lib/api/token-store";
10
+ import { signInSchema } from "@/features/auth/validations";
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
+ };
21
+
22
+ export function SignInForm() {
23
+ const router = useRouter();
24
+ const t = useTranslations("auth.signInForm");
25
+ const [username, setUsername] = useState("");
26
+ const [password, setPassword] = useState("");
27
+ const [isSubmitting, setIsSubmitting] = useState(false);
28
+
29
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
30
+ event.preventDefault();
31
+
32
+ const parsed = signInSchema.safeParse({ username, password });
33
+ if (!parsed.success) {
34
+ toast.error(t("invalidInput"));
35
+ return;
36
+ }
37
+
38
+ try {
39
+ setIsSubmitting(true);
40
+ const response = await publicApi.post("/api/v1/auth/login", parsed.data, {
41
+ withCredentials: true,
42
+ });
43
+ const payload = (response.data as ApiSuccess<LoginResponse>).data;
44
+ if (!payload?.accessToken) {
45
+ toast.error(t("missingAccessToken"));
46
+ return;
47
+ }
48
+
49
+ setAccessToken(payload.accessToken);
50
+ toast.success(t("success"));
51
+ router.push(payload.requirePasswordChange ? "/change-password" : "/dashboard");
52
+ router.refresh();
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : t("failed");
55
+ toast.error(message);
56
+ } finally {
57
+ setIsSubmitting(false);
58
+ }
59
+ };
60
+
61
+ return (
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>
94
+ );
95
+ }
@@ -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,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 };