create-solostack 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/LICENSE +21 -0
- package/README.md +262 -0
- package/bin/cli.js +13 -0
- package/package.json +45 -0
- package/src/constants.js +94 -0
- package/src/generators/auth.js +595 -0
- package/src/generators/base.js +592 -0
- package/src/generators/database.js +365 -0
- package/src/generators/emails.js +404 -0
- package/src/generators/payments.js +541 -0
- package/src/generators/ui.js +368 -0
- package/src/index.js +374 -0
- package/src/utils/files.js +81 -0
- package/src/utils/git.js +69 -0
- package/src/utils/logger.js +62 -0
- package/src/utils/packages.js +75 -0
- package/src/utils/validate.js +17 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { writeFile, ensureDir } from '../utils/files.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates NextAuth.js authentication configuration
|
|
6
|
+
* @param {string} projectPath - Path where the project is located
|
|
7
|
+
* @param {string} authProvider - Auth provider type
|
|
8
|
+
*/
|
|
9
|
+
export async function generateAuth(projectPath, authProvider) {
|
|
10
|
+
// Create auth directory
|
|
11
|
+
await ensureDir(path.join(projectPath, 'src/app/api/auth/[...nextauth]'));
|
|
12
|
+
await ensureDir(path.join(projectPath, 'src/app/(auth)/login'));
|
|
13
|
+
await ensureDir(path.join(projectPath, 'src/app/(auth)/signup'));
|
|
14
|
+
|
|
15
|
+
// Generate auth.config.ts
|
|
16
|
+
const authConfig = `import type { NextAuthConfig } from 'next-auth';
|
|
17
|
+
import Credentials from 'next-auth/providers/credentials';
|
|
18
|
+
import Google from 'next-auth/providers/google';
|
|
19
|
+
import GitHub from 'next-auth/providers/github';
|
|
20
|
+
import { db } from '@/lib/db';
|
|
21
|
+
import bcrypt from 'bcryptjs';
|
|
22
|
+
|
|
23
|
+
export const authConfig = {
|
|
24
|
+
providers: [
|
|
25
|
+
Google({
|
|
26
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
27
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
28
|
+
}),
|
|
29
|
+
GitHub({
|
|
30
|
+
clientId: process.env.GITHUB_CLIENT_ID,
|
|
31
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
|
32
|
+
}),
|
|
33
|
+
Credentials({
|
|
34
|
+
credentials: {
|
|
35
|
+
email: { label: 'Email', type: 'email' },
|
|
36
|
+
password: { label: 'Password', type: 'password' },
|
|
37
|
+
},
|
|
38
|
+
async authorize(credentials) {
|
|
39
|
+
if (!credentials?.email || !credentials?.password) {
|
|
40
|
+
throw new Error('Missing credentials');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const user = await db.user.findUnique({
|
|
44
|
+
where: { email: credentials.email as string },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!user || !user.password) {
|
|
48
|
+
throw new Error('Invalid credentials');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isValid = await bcrypt.compare(
|
|
52
|
+
credentials.password as string,
|
|
53
|
+
user.password
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!isValid) {
|
|
57
|
+
throw new Error('Invalid credentials');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: user.id,
|
|
62
|
+
email: user.email,
|
|
63
|
+
name: user.name,
|
|
64
|
+
image: user.image,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
],
|
|
69
|
+
pages: {
|
|
70
|
+
signIn: '/login',
|
|
71
|
+
signOut: '/login',
|
|
72
|
+
error: '/login',
|
|
73
|
+
},
|
|
74
|
+
callbacks: {
|
|
75
|
+
async session({ session, token }) {
|
|
76
|
+
if (token) {
|
|
77
|
+
session.user.id = token.id as string;
|
|
78
|
+
session.user.role = token.role as string;
|
|
79
|
+
}
|
|
80
|
+
return session;
|
|
81
|
+
},
|
|
82
|
+
async jwt({ token, user }) {
|
|
83
|
+
if (user) {
|
|
84
|
+
const dbUser = await db.user.findUnique({
|
|
85
|
+
where: { email: user.email! },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (dbUser) {
|
|
89
|
+
token.id = dbUser.id;
|
|
90
|
+
token.role = dbUser.role;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return token;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
session: {
|
|
97
|
+
strategy: 'jwt',
|
|
98
|
+
},
|
|
99
|
+
} satisfies NextAuthConfig;
|
|
100
|
+
`;
|
|
101
|
+
|
|
102
|
+
await writeFile(path.join(projectPath, 'src/lib/auth.config.ts'), authConfig);
|
|
103
|
+
|
|
104
|
+
// Generate auth.ts
|
|
105
|
+
const auth = `import NextAuth from 'next-auth';
|
|
106
|
+
import { PrismaAdapter } from '@auth/prisma-adapter';
|
|
107
|
+
import { authConfig } from './auth.config';
|
|
108
|
+
import { db } from './db';
|
|
109
|
+
|
|
110
|
+
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
111
|
+
adapter: PrismaAdapter(db),
|
|
112
|
+
...authConfig,
|
|
113
|
+
});
|
|
114
|
+
`;
|
|
115
|
+
|
|
116
|
+
await writeFile(path.join(projectPath, 'src/lib/auth.ts'), auth);
|
|
117
|
+
|
|
118
|
+
// Generate API route handler
|
|
119
|
+
const apiRoute = `import { handlers } from '@/lib/auth';
|
|
120
|
+
|
|
121
|
+
export const { GET, POST } = handlers;
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
await writeFile(
|
|
125
|
+
path.join(projectPath, 'src/app/api/auth/[...nextauth]/route.ts'),
|
|
126
|
+
apiRoute
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Generate middleware.ts
|
|
130
|
+
const middleware = `import { auth } from '@/lib/auth';
|
|
131
|
+
import { NextResponse } from 'next/server';
|
|
132
|
+
|
|
133
|
+
export default auth((req) => {
|
|
134
|
+
const isLoggedIn = !!req.auth;
|
|
135
|
+
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard');
|
|
136
|
+
const isOnAuth = req.nextUrl.pathname.startsWith('/login') ||
|
|
137
|
+
req.nextUrl.pathname.startsWith('/signup');
|
|
138
|
+
|
|
139
|
+
if (isOnDashboard && !isLoggedIn) {
|
|
140
|
+
return NextResponse.redirect(new URL('/login', req.url));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isOnAuth && isLoggedIn) {
|
|
144
|
+
return NextResponse.redirect(new URL('/dashboard', req.url));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return NextResponse.next();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const config = {
|
|
151
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
|
152
|
+
};
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
await writeFile(path.join(projectPath, 'middleware.ts'), middleware);
|
|
156
|
+
|
|
157
|
+
// Generate login page
|
|
158
|
+
const loginPage = `'use client';
|
|
159
|
+
|
|
160
|
+
import { useState } from 'react';
|
|
161
|
+
import { signIn } from 'next-auth/react';
|
|
162
|
+
import { useRouter } from 'next/navigation';
|
|
163
|
+
import Link from 'next/link';
|
|
164
|
+
|
|
165
|
+
export default function LoginPage() {
|
|
166
|
+
const router = useRouter();
|
|
167
|
+
const [email, setEmail] = useState('');
|
|
168
|
+
const [password, setPassword] = useState('');
|
|
169
|
+
const [error, setError] = useState('');
|
|
170
|
+
const [loading, setLoading] = useState(false);
|
|
171
|
+
|
|
172
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
setLoading(true);
|
|
175
|
+
setError('');
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const result = await signIn('credentials', {
|
|
179
|
+
email,
|
|
180
|
+
password,
|
|
181
|
+
redirect: false,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (result?.error) {
|
|
185
|
+
setError('Invalid email or password');
|
|
186
|
+
} else {
|
|
187
|
+
router.push('/dashboard');
|
|
188
|
+
router.refresh();
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
setError('An error occurred. Please try again.');
|
|
192
|
+
} finally {
|
|
193
|
+
setLoading(false);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
|
199
|
+
<div className="w-full max-w-md space-y-8">
|
|
200
|
+
<div>
|
|
201
|
+
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
|
202
|
+
Sign in to your account
|
|
203
|
+
</h2>
|
|
204
|
+
</div>
|
|
205
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
206
|
+
{error && (
|
|
207
|
+
<div className="rounded-md bg-red-50 p-4">
|
|
208
|
+
<p className="text-sm text-red-800">{error}</p>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
<div className="-space-y-px rounded-md shadow-sm">
|
|
212
|
+
<div>
|
|
213
|
+
<label htmlFor="email" className="sr-only">
|
|
214
|
+
Email address
|
|
215
|
+
</label>
|
|
216
|
+
<input
|
|
217
|
+
id="email"
|
|
218
|
+
name="email"
|
|
219
|
+
type="email"
|
|
220
|
+
autoComplete="email"
|
|
221
|
+
required
|
|
222
|
+
value={email}
|
|
223
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
224
|
+
className="relative block w-full rounded-t-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
|
|
225
|
+
placeholder="Email address"
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<div>
|
|
229
|
+
<label htmlFor="password" className="sr-only">
|
|
230
|
+
Password
|
|
231
|
+
</label>
|
|
232
|
+
<input
|
|
233
|
+
id="password"
|
|
234
|
+
name="password"
|
|
235
|
+
type="password"
|
|
236
|
+
autoComplete="current-password"
|
|
237
|
+
required
|
|
238
|
+
value={password}
|
|
239
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
240
|
+
className="relative block w-full rounded-b-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
|
|
241
|
+
placeholder="Password"
|
|
242
|
+
/>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div>
|
|
247
|
+
<button
|
|
248
|
+
type="submit"
|
|
249
|
+
disabled={loading}
|
|
250
|
+
className="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
|
|
251
|
+
>
|
|
252
|
+
{loading ? 'Signing in...' : 'Sign in'}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="flex flex-col space-y-4">
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
|
|
260
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50"
|
|
261
|
+
>
|
|
262
|
+
Continue with Google
|
|
263
|
+
</button>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
|
|
267
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50"
|
|
268
|
+
>
|
|
269
|
+
Continue with GitHub
|
|
270
|
+
</button>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="text-center text-sm">
|
|
274
|
+
<span className="text-gray-600">Don't have an account? </span>
|
|
275
|
+
<Link href="/signup" className="font-semibold text-indigo-600 hover:text-indigo-500">
|
|
276
|
+
Sign up
|
|
277
|
+
</Link>
|
|
278
|
+
</div>
|
|
279
|
+
</form>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
await writeFile(path.join(projectPath, 'src/app/(auth)/login/page.tsx'), loginPage);
|
|
287
|
+
|
|
288
|
+
// Generate signup page
|
|
289
|
+
const signupPage = `'use client';
|
|
290
|
+
|
|
291
|
+
import { useState } from 'react';
|
|
292
|
+
import { useRouter } from 'next/navigation';
|
|
293
|
+
import { signIn } from 'next-auth/react';
|
|
294
|
+
import Link from 'next/link';
|
|
295
|
+
|
|
296
|
+
export default function SignupPage() {
|
|
297
|
+
const router = useRouter();
|
|
298
|
+
const [name, setName] = useState('');
|
|
299
|
+
const [email, setEmail] = useState('');
|
|
300
|
+
const [password, setPassword] = useState('');
|
|
301
|
+
const [error, setError] = useState('');
|
|
302
|
+
const [loading, setLoading] = useState(false);
|
|
303
|
+
|
|
304
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
setLoading(true);
|
|
307
|
+
setError('');
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const res = await fetch('/api/auth/signup', {
|
|
311
|
+
method: 'POST',
|
|
312
|
+
headers: { 'Content-Type': 'application/json' },
|
|
313
|
+
body: JSON.stringify({ name, email, password }),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
if (!res.ok) {
|
|
317
|
+
const data = await res.json();
|
|
318
|
+
setError(data.error || 'Something went wrong');
|
|
319
|
+
setLoading(false);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Sign in after successful signup
|
|
324
|
+
await signIn('credentials', {
|
|
325
|
+
email,
|
|
326
|
+
password,
|
|
327
|
+
redirect: false,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
router.push('/dashboard');
|
|
331
|
+
router.refresh();
|
|
332
|
+
} catch (error) {
|
|
333
|
+
setError('An error occurred. Please try again.');
|
|
334
|
+
setLoading(false);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
|
|
340
|
+
<div className="w-full max-w-md space-y-8">
|
|
341
|
+
<div>
|
|
342
|
+
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
|
|
343
|
+
Create your account
|
|
344
|
+
</h2>
|
|
345
|
+
</div>
|
|
346
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
347
|
+
{error && (
|
|
348
|
+
<div className="rounded-md bg-red-50 p-4">
|
|
349
|
+
<p className="text-sm text-red-800">{error}</p>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
<div className="-space-y-px rounded-md shadow-sm">
|
|
353
|
+
<div>
|
|
354
|
+
<label htmlFor="name" className="sr-only">
|
|
355
|
+
Full name
|
|
356
|
+
</label>
|
|
357
|
+
<input
|
|
358
|
+
id="name"
|
|
359
|
+
name="name"
|
|
360
|
+
type="text"
|
|
361
|
+
required
|
|
362
|
+
value={name}
|
|
363
|
+
onChange={(e) => setName(e.target.value)}
|
|
364
|
+
className="relative block w-full rounded-t-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
|
|
365
|
+
placeholder="Full name"
|
|
366
|
+
/>
|
|
367
|
+
</div>
|
|
368
|
+
<div>
|
|
369
|
+
<label htmlFor="email" className="sr-only">
|
|
370
|
+
Email address
|
|
371
|
+
</label>
|
|
372
|
+
<input
|
|
373
|
+
id="email"
|
|
374
|
+
name="email"
|
|
375
|
+
type="email"
|
|
376
|
+
autoComplete="email"
|
|
377
|
+
required
|
|
378
|
+
value={email}
|
|
379
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
380
|
+
className="relative block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
|
|
381
|
+
placeholder="Email address"
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
<div>
|
|
385
|
+
<label htmlFor="password" className="sr-only">
|
|
386
|
+
Password
|
|
387
|
+
</label>
|
|
388
|
+
<input
|
|
389
|
+
id="password"
|
|
390
|
+
name="password"
|
|
391
|
+
type="password"
|
|
392
|
+
autoComplete="new-password"
|
|
393
|
+
required
|
|
394
|
+
value={password}
|
|
395
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
396
|
+
className="relative block w-full rounded-b-md border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 px-3"
|
|
397
|
+
placeholder="Password"
|
|
398
|
+
/>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div>
|
|
403
|
+
<button
|
|
404
|
+
type="submit"
|
|
405
|
+
disabled={loading}
|
|
406
|
+
className="group relative flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"
|
|
407
|
+
>
|
|
408
|
+
{loading ? 'Creating account...' : 'Sign up'}
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div className="flex flex-col space-y-4">
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
|
|
416
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50"
|
|
417
|
+
>
|
|
418
|
+
Continue with Google
|
|
419
|
+
</button>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => signIn('github', { callbackUrl: '/dashboard' })}
|
|
423
|
+
className="flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50"
|
|
424
|
+
>
|
|
425
|
+
Continue with GitHub
|
|
426
|
+
</button>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
<div className="text-center text-sm">
|
|
430
|
+
<span className="text-gray-600">Already have an account? </span>
|
|
431
|
+
<Link href="/login" className="font-semibold text-indigo-600 hover:text-indigo-500">
|
|
432
|
+
Sign in
|
|
433
|
+
</Link>
|
|
434
|
+
</div>
|
|
435
|
+
</form>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
`;
|
|
441
|
+
|
|
442
|
+
await writeFile(path.join(projectPath, 'src/app/(auth)/signup/page.tsx'), signupPage);
|
|
443
|
+
|
|
444
|
+
// Generate signup API route
|
|
445
|
+
await ensureDir(path.join(projectPath, 'src/app/api/auth/signup'));
|
|
446
|
+
|
|
447
|
+
const signupApi = `import { NextRequest, NextResponse } from 'next/server';
|
|
448
|
+
import { db } from '@/lib/db';
|
|
449
|
+
import { stripe } from '@/lib/stripe';
|
|
450
|
+
import bcrypt from 'bcryptjs';
|
|
451
|
+
import { generateVerificationToken } from '@/lib/tokens';
|
|
452
|
+
import { sendVerificationEmail } from '@/lib/email';
|
|
453
|
+
|
|
454
|
+
export async function POST(req: NextRequest) {
|
|
455
|
+
try {
|
|
456
|
+
const { name, email, password } = await req.json();
|
|
457
|
+
|
|
458
|
+
if (!email || !password) {
|
|
459
|
+
return NextResponse.json(
|
|
460
|
+
{ error: 'Email and password are required' },
|
|
461
|
+
{ status: 400 }
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Check if user already exists
|
|
466
|
+
const existingUser = await db.user.findUnique({
|
|
467
|
+
where: { email },
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (existingUser) {
|
|
471
|
+
return NextResponse.json(
|
|
472
|
+
{ error: 'User already exists' },
|
|
473
|
+
{ status: 400 }
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Hash password
|
|
478
|
+
const hashedPassword = await bcrypt.hash(password, 10);
|
|
479
|
+
|
|
480
|
+
// Create Stripe customer
|
|
481
|
+
const customer = await stripe.customers.create({
|
|
482
|
+
email,
|
|
483
|
+
name: name || undefined,
|
|
484
|
+
metadata: {
|
|
485
|
+
source: 'signup',
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Create user with Stripe customer ID
|
|
490
|
+
const user = await db.user.create({
|
|
491
|
+
data: {
|
|
492
|
+
name,
|
|
493
|
+
email,
|
|
494
|
+
password: hashedPassword,
|
|
495
|
+
stripeCustomerId: customer.id,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Generate verification token and send email (optional)
|
|
500
|
+
try {
|
|
501
|
+
const token = await generateVerificationToken(email);
|
|
502
|
+
const verificationUrl = \`\${process.env.NEXTAUTH_URL}/api/auth/verify?token=\${token}\`;
|
|
503
|
+
await sendVerificationEmail(email, verificationUrl);
|
|
504
|
+
} catch (emailError) {
|
|
505
|
+
console.error('Failed to send verification email:', emailError);
|
|
506
|
+
// Don't fail signup if email fails
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return NextResponse.json(
|
|
510
|
+
{ message: 'User created successfully', userId: user.id },
|
|
511
|
+
{ status: 201 }
|
|
512
|
+
);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error('Signup error:', error);
|
|
515
|
+
return NextResponse.json(
|
|
516
|
+
{ error: 'An error occurred during signup' },
|
|
517
|
+
{ status: 500 }
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
`;
|
|
522
|
+
|
|
523
|
+
await writeFile(path.join(projectPath, 'src/app/api/auth/signup/route.ts'), signupApi);
|
|
524
|
+
|
|
525
|
+
// Generate token utility
|
|
526
|
+
const tokenUtility = `import crypto from 'crypto';
|
|
527
|
+
import { db } from './db';
|
|
528
|
+
|
|
529
|
+
export async function generateVerificationToken(email: string): Promise<string> {
|
|
530
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
531
|
+
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
532
|
+
|
|
533
|
+
await db.verificationToken.deleteMany({ where: { identifier: email } });
|
|
534
|
+
await db.verificationToken.create({ data: { identifier: email, token, expires } });
|
|
535
|
+
|
|
536
|
+
return token;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function verifyToken(token: string): Promise<{ success: boolean; email?: string }> {
|
|
540
|
+
const verificationToken = await db.verificationToken.findUnique({ where: { token } });
|
|
541
|
+
if (!verificationToken || verificationToken.expires < new Date()) {
|
|
542
|
+
if (verificationToken) await db.verificationToken.delete({ where: { token } });
|
|
543
|
+
return { success: false };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
await db.user.update({
|
|
547
|
+
where: { email: verificationToken.identifier },
|
|
548
|
+
data: { emailVerified: new Date() },
|
|
549
|
+
});
|
|
550
|
+
await db.verificationToken.delete({ where: { token } });
|
|
551
|
+
|
|
552
|
+
return { success: true, email: verificationToken.identifier };
|
|
553
|
+
}
|
|
554
|
+
`;
|
|
555
|
+
|
|
556
|
+
await writeFile(path.join(projectPath, 'src/lib/tokens.ts'), tokenUtility);
|
|
557
|
+
|
|
558
|
+
// Generate email verification API route
|
|
559
|
+
await ensureDir(path.join(projectPath, 'src/app/api/auth/verify'));
|
|
560
|
+
|
|
561
|
+
const verifyApi = `import { NextRequest, NextResponse } from 'next/server';
|
|
562
|
+
import { verifyToken } from '@/lib/tokens';
|
|
563
|
+
|
|
564
|
+
export async function GET(req: NextRequest) {
|
|
565
|
+
const { searchParams } = new URL(req.url);
|
|
566
|
+
const token = searchParams.get('token');
|
|
567
|
+
if (!token) return NextResponse.json({ error: 'Token required' }, { status: 400 });
|
|
568
|
+
|
|
569
|
+
const result = await verifyToken(token);
|
|
570
|
+
if (!result.success) return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
|
|
571
|
+
|
|
572
|
+
return NextResponse.redirect(new URL('/login?verified=true', req.url));
|
|
573
|
+
}
|
|
574
|
+
`;
|
|
575
|
+
|
|
576
|
+
await writeFile(path.join(projectPath, 'src/app/api/auth/verify/route.ts'), verifyApi);
|
|
577
|
+
|
|
578
|
+
// Generate verify page
|
|
579
|
+
await ensureDir(path.join(projectPath, 'src/app/(auth)/verify'));
|
|
580
|
+
const verifyPage = `export default function VerifyPage() {
|
|
581
|
+
return (
|
|
582
|
+
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12">
|
|
583
|
+
<div className="w-full max-w-md text-center">
|
|
584
|
+
<h2 className="text-3xl font-bold text-gray-900">Check Your Email</h2>
|
|
585
|
+
<p className="mt-2 text-sm text-gray-600">
|
|
586
|
+
We've sent you a verification link. Click it to verify your account.
|
|
587
|
+
</p>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
`;
|
|
593
|
+
|
|
594
|
+
await writeFile(path.join(projectPath, 'src/app/(auth)/verify/page.tsx'), verifyPage);
|
|
595
|
+
}
|