@thinhnguyencth1204/nextcli 0.1.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 +197 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1439 -0
- package/package.json +43 -0
- package/templates/features/chat/src/app/api/v1/chat/route.ts +40 -0
- package/templates/features/chat/src/features/chat/api/use-chat-history.ts +18 -0
- package/templates/features/chat/src/features/chat/api/use-realtime-sync.ts +15 -0
- package/templates/features/chat/src/features/chat/api/use-send-message.ts +35 -0
- package/templates/features/chat/src/features/chat/components/ChatWidget.tsx +40 -0
- package/templates/features/chat/src/features/chat/services.ts +27 -0
- package/templates/features/seo/public/robots.txt +3 -0
- package/templates/features/seo/public/sitemap.xml +6 -0
- package/templates/features/seo/src/app/robots.ts +13 -0
- package/templates/features/seo/src/app/sitemap.ts +21 -0
- package/templates/features/seo/src/components/seo/json-ld.tsx +14 -0
- package/templates/features/supabase/src/lib/supabase/client.ts +9 -0
- package/templates/features/supabase/src/lib/supabase/storage-config.ts +69 -0
- package/templates/features/supabase/src/lib/supabase/storage.ts +167 -0
- package/templates/features/supabase-realtime/src/features/supabase-realtime/client.ts +9 -0
- package/templates/features/supabase-realtime/src/features/supabase-realtime/use-supabase-channel.ts +19 -0
- package/templates/next-base/.env +11 -0
- package/templates/next-base/.env.development +11 -0
- package/templates/next-base/.env.example +11 -0
- package/templates/next-base/eslint.config.mjs +20 -0
- package/templates/next-base/middleware.ts +10 -0
- package/templates/next-base/next-env.d.ts +4 -0
- package/templates/next-base/next.config.ts +7 -0
- package/templates/next-base/package.json +45 -0
- package/templates/next-base/prisma/migrations/.gitkeep +1 -0
- package/templates/next-base/prisma/schema.prisma +72 -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)/sign-in/page.tsx +11 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +14 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +10 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +60 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +26 -0
- package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- package/templates/next-base/src/app/layout.tsx +28 -0
- package/templates/next-base/src/app/page.tsx +21 -0
- package/templates/next-base/src/app/styles.css +12 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
- package/templates/next-base/src/components/ui/button.tsx +16 -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 +66 -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 +62 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +77 -0
- package/templates/next-base/src/features/auth/validations.ts +8 -0
- package/templates/next-base/src/hooks/index.ts +1 -0
- package/templates/next-base/src/i18n/request.ts +8 -0
- package/templates/next-base/src/lib/api-response.ts +49 -0
- package/templates/next-base/src/lib/auth-client.ts +7 -0
- package/templates/next-base/src/lib/auth-cookies.ts +15 -0
- package/templates/next-base/src/lib/auth.ts +20 -0
- package/templates/next-base/src/lib/axios-instance.ts +140 -0
- package/templates/next-base/src/lib/prisma.ts +13 -0
- package/templates/next-base/src/lib/token-store.ts +13 -0
- package/templates/next-base/src/types/index.ts +40 -0
- package/templates/next-base/src/utils/cn.ts +6 -0
- package/templates/next-base/tsconfig.json +24 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { FormEvent } from "react";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { publicApi } from "@/lib/axios-instance";
|
|
8
|
+
import { setAccessToken } from "@/lib/token-store";
|
|
9
|
+
import { signInSchema } from "@/features/auth/validations";
|
|
10
|
+
import type { ApiSuccess } from "@/types";
|
|
11
|
+
|
|
12
|
+
export function SignInForm() {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const [email, setEmail] = useState("");
|
|
15
|
+
const [password, setPassword] = useState("");
|
|
16
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
17
|
+
|
|
18
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
|
|
21
|
+
const parsed = signInSchema.safeParse({ email, password });
|
|
22
|
+
if (!parsed.success) {
|
|
23
|
+
toast.error("Please provide a valid email and password.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
setIsSubmitting(true);
|
|
29
|
+
const response = await publicApi.post("/api/v1/auth/login", parsed.data, {
|
|
30
|
+
withCredentials: true,
|
|
31
|
+
});
|
|
32
|
+
const accessToken = (response.data as ApiSuccess<{ accessToken: string }>).data?.accessToken;
|
|
33
|
+
if (!accessToken) {
|
|
34
|
+
toast.error("Access token is missing.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setAccessToken(accessToken);
|
|
39
|
+
toast.success("Signed in successfully.");
|
|
40
|
+
router.push("/account");
|
|
41
|
+
router.refresh();
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const message = error instanceof Error ? error.message : "Sign in failed.";
|
|
44
|
+
toast.error(message);
|
|
45
|
+
} finally {
|
|
46
|
+
setIsSubmitting(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
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>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
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(
|
|
34
|
+
code: ErrorCode,
|
|
35
|
+
message: string,
|
|
36
|
+
init: FailInit = {},
|
|
37
|
+
) {
|
|
38
|
+
const payload: ApiErrorResponse = {
|
|
39
|
+
success: false,
|
|
40
|
+
error: {
|
|
41
|
+
code,
|
|
42
|
+
message,
|
|
43
|
+
details: init.details,
|
|
44
|
+
},
|
|
45
|
+
timestamp: createTimestamp(),
|
|
46
|
+
requestId: init.requestId,
|
|
47
|
+
};
|
|
48
|
+
return NextResponse.json(payload, { status: init.status ?? 500 });
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const refreshCookieName = "nextcli_refresh_token";
|
|
2
|
+
|
|
3
|
+
export function refreshCookieOptions() {
|
|
4
|
+
return {
|
|
5
|
+
httpOnly: true,
|
|
6
|
+
secure: process.env.NODE_ENV === "production",
|
|
7
|
+
sameSite: "lax",
|
|
8
|
+
path: "/",
|
|
9
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
10
|
+
} as const;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getRefreshCookieName(): string {
|
|
14
|
+
return refreshCookieName;
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth/minimal";
|
|
2
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
3
|
+
import { jwt } from "better-auth/plugins";
|
|
4
|
+
import prisma from "@/lib/prisma";
|
|
5
|
+
|
|
6
|
+
const socialProviders = {
|
|
7
|
+
// AUTO_GENERATED_AUTH_PROVIDERS_START
|
|
8
|
+
// AUTO_GENERATED_AUTH_PROVIDERS_END
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const auth = betterAuth({
|
|
12
|
+
database: prismaAdapter(prisma, {
|
|
13
|
+
provider: "postgresql",
|
|
14
|
+
}),
|
|
15
|
+
plugins: [jwt()],
|
|
16
|
+
emailAndPassword: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
},
|
|
19
|
+
socialProviders,
|
|
20
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import { clearAccessToken, getAccessToken, setAccessToken } from "@/lib/token-store";
|
|
3
|
+
import type { ApiErrorResponse, ApiSuccess } from "@/types";
|
|
4
|
+
|
|
5
|
+
const baseURL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
|
6
|
+
|
|
7
|
+
type RetryableRequestConfig = {
|
|
8
|
+
_retry?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const publicApi = axios.create({
|
|
12
|
+
baseURL,
|
|
13
|
+
withCredentials: true,
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const protectedApi = axios.create({
|
|
20
|
+
baseURL,
|
|
21
|
+
withCredentials: true,
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
protectedApi.interceptors.request.use((config) => {
|
|
28
|
+
const token = getAccessToken();
|
|
29
|
+
if (token) {
|
|
30
|
+
config.headers = config.headers ?? {};
|
|
31
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
32
|
+
}
|
|
33
|
+
return config;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let isRefreshing = false;
|
|
37
|
+
let waitingQueue: Array<(token: string | null) => void> = [];
|
|
38
|
+
|
|
39
|
+
function flushQueue(token: string | null): void {
|
|
40
|
+
for (const resolve of waitingQueue) {
|
|
41
|
+
resolve(token);
|
|
42
|
+
}
|
|
43
|
+
waitingQueue = [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function refreshAccessToken(): Promise<string | null> {
|
|
47
|
+
try {
|
|
48
|
+
const response = await publicApi.post("/api/v1/auth/refresh", null, {
|
|
49
|
+
withCredentials: true,
|
|
50
|
+
});
|
|
51
|
+
const token = (response.data as ApiSuccess<{ accessToken: string }>).data?.accessToken;
|
|
52
|
+
if (!token) {
|
|
53
|
+
clearAccessToken();
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
setAccessToken(token);
|
|
57
|
+
return token;
|
|
58
|
+
} catch {
|
|
59
|
+
clearAccessToken();
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protectedApi.interceptors.response.use(
|
|
65
|
+
(response) => response,
|
|
66
|
+
async (error) => {
|
|
67
|
+
const config = error.config as typeof error.config & RetryableRequestConfig;
|
|
68
|
+
if (!config || config._retry || error.response?.status !== 401) {
|
|
69
|
+
return Promise.reject(error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isRefreshing) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
waitingQueue.push((token) => {
|
|
75
|
+
if (!token) {
|
|
76
|
+
reject(error);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
config.headers = config.headers ?? {};
|
|
81
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
82
|
+
resolve(protectedApi(config));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
config._retry = true;
|
|
88
|
+
isRefreshing = true;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const token = await refreshAccessToken();
|
|
92
|
+
flushQueue(token);
|
|
93
|
+
if (!token) {
|
|
94
|
+
return Promise.reject(error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
config.headers = config.headers ?? {};
|
|
98
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
99
|
+
return protectedApi(config);
|
|
100
|
+
} finally {
|
|
101
|
+
isRefreshing = false;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
function extractApiErrorMessage(error: unknown): string {
|
|
107
|
+
if (!axios.isAxiosError(error)) {
|
|
108
|
+
return "Unexpected error occurred.";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const payload = error.response?.data as ApiErrorResponse | undefined;
|
|
112
|
+
if (payload && payload.success === false) {
|
|
113
|
+
return payload.error.message;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return error.message || "Request failed.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
publicApi.interceptors.response.use(
|
|
120
|
+
(response) => response,
|
|
121
|
+
async (error) => {
|
|
122
|
+
if (axios.isAxiosError(error)) {
|
|
123
|
+
error.message = extractApiErrorMessage(error);
|
|
124
|
+
}
|
|
125
|
+
return Promise.reject(error);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
protectedApi.interceptors.response.use(
|
|
130
|
+
(response) => response,
|
|
131
|
+
async (error) => {
|
|
132
|
+
if (axios.isAxiosError(error)) {
|
|
133
|
+
error.message = extractApiErrorMessage(error);
|
|
134
|
+
}
|
|
135
|
+
return Promise.reject(error);
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
export const api = publicApi;
|
|
140
|
+
export { extractApiErrorMessage };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { PrismaClient } from "@prisma/client";
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
var prisma: PrismaClient | undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const prisma = global.prisma ?? new PrismaClient();
|
|
8
|
+
|
|
9
|
+
if (process.env.NODE_ENV !== "production") {
|
|
10
|
+
global.prisma = prisma;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default prisma;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
let accessToken: string | null = null;
|
|
2
|
+
|
|
3
|
+
export function getAccessToken(): string | null {
|
|
4
|
+
return accessToken;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function setAccessToken(token: string | null): void {
|
|
8
|
+
accessToken = token;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clearAccessToken(): void {
|
|
12
|
+
accessToken = null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type ErrorCode =
|
|
2
|
+
| "BAD_REQUEST"
|
|
3
|
+
| "UNAUTHORIZED"
|
|
4
|
+
| "FORBIDDEN"
|
|
5
|
+
| "NOT_FOUND"
|
|
6
|
+
| "CONFLICT"
|
|
7
|
+
| "VALIDATION_ERROR"
|
|
8
|
+
| "INTERNAL_ERROR"
|
|
9
|
+
| "UPSTREAM_ERROR";
|
|
10
|
+
|
|
11
|
+
export type ApiMeta = {
|
|
12
|
+
timestamp: string;
|
|
13
|
+
requestId?: string;
|
|
14
|
+
pagination?: {
|
|
15
|
+
page: number;
|
|
16
|
+
pageSize: number;
|
|
17
|
+
total: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ApiSuccess<T> = {
|
|
22
|
+
success: true;
|
|
23
|
+
data: T;
|
|
24
|
+
meta: ApiMeta;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ApiErrorShape = {
|
|
28
|
+
code: ErrorCode;
|
|
29
|
+
message: string;
|
|
30
|
+
details?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ApiErrorResponse = {
|
|
34
|
+
success: false;
|
|
35
|
+
error: ApiErrorShape;
|
|
36
|
+
timestamp: string;
|
|
37
|
+
requestId?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ApiResponse<T> = ApiSuccess<T> | ApiErrorResponse;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"types": ["node", "react", "react-dom"],
|
|
16
|
+
"incremental": true,
|
|
17
|
+
"plugins": [{ "name": "next" }],
|
|
18
|
+
"paths": {
|
|
19
|
+
"@/*": ["./src/*"]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
23
|
+
"exclude": ["node_modules"]
|
|
24
|
+
}
|