create-sks-admin 0.1.1 → 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/bin/cli.js +793 -108
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -4,15 +4,29 @@ import { execSync } from 'child_process'
|
|
|
4
4
|
import fs from 'fs'
|
|
5
5
|
import path from 'path'
|
|
6
6
|
import { fileURLToPath } from 'url'
|
|
7
|
+
import { createHash } from 'crypto'
|
|
8
|
+
import prompts from 'prompts'
|
|
7
9
|
|
|
8
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
11
|
|
|
12
|
+
// License key validation (SHA256 hashed - original key not visible)
|
|
13
|
+
const VALID_KEY_HASHES = [
|
|
14
|
+
'c34e63049262e049b26ea19f39b4a9635e6bab0fa7754e7dbf1b66d6b53de680', // Primary key
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
function validateLicenseKey(key) {
|
|
18
|
+
const hash = createHash('sha256').update(key.trim()).digest('hex')
|
|
19
|
+
return VALID_KEY_HASHES.includes(hash)
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
// Colors for terminal
|
|
11
23
|
const colors = {
|
|
12
24
|
green: (t) => `\x1b[32m${t}\x1b[0m`,
|
|
13
25
|
blue: (t) => `\x1b[34m${t}\x1b[0m`,
|
|
14
26
|
yellow: (t) => `\x1b[33m${t}\x1b[0m`,
|
|
15
27
|
red: (t) => `\x1b[31m${t}\x1b[0m`,
|
|
28
|
+
cyan: (t) => `\x1b[36m${t}\x1b[0m`,
|
|
29
|
+
magenta: (t) => `\x1b[35m${t}\x1b[0m`,
|
|
16
30
|
}
|
|
17
31
|
|
|
18
32
|
const log = {
|
|
@@ -23,13 +37,24 @@ const log = {
|
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
// Get project name from args
|
|
26
|
-
const projectName = process.argv[2]
|
|
40
|
+
const projectName = process.argv[2]
|
|
41
|
+
|
|
42
|
+
if (!projectName) {
|
|
43
|
+
console.log(`
|
|
44
|
+
${colors.cyan('Usage:')} npx create-sks-admin ${colors.yellow('<project-name>')}
|
|
45
|
+
|
|
46
|
+
${colors.cyan('Examples:')}
|
|
47
|
+
npx create-sks-admin my-admin-app
|
|
48
|
+
npx create-sks-admin . ${colors.blue('(current directory)')}
|
|
49
|
+
`)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
27
52
|
|
|
28
53
|
console.log(`
|
|
29
|
-
|
|
30
|
-
║
|
|
31
|
-
║
|
|
32
|
-
|
|
54
|
+
${colors.cyan('╔═══════════════════════════════════════════════════╗')}
|
|
55
|
+
${colors.cyan('║')} ${colors.magenta('SKS Admin Panel')} - Production Ready ${colors.cyan('║')}
|
|
56
|
+
${colors.cyan('║')} ${colors.blue('Next.js 16 + Prisma + NextAuth + Shadcn')} ${colors.cyan('║')}
|
|
57
|
+
${colors.cyan('╚═══════════════════════════════════════════════════╝')}
|
|
33
58
|
`)
|
|
34
59
|
|
|
35
60
|
const projectPath = path.resolve(process.cwd(), projectName)
|
|
@@ -41,84 +66,470 @@ if (!isCurrentDir && fs.existsSync(projectPath)) {
|
|
|
41
66
|
process.exit(1)
|
|
42
67
|
}
|
|
43
68
|
|
|
44
|
-
// For current directory, check if it's empty
|
|
69
|
+
// For current directory, check if it's empty
|
|
45
70
|
if (isCurrentDir) {
|
|
46
71
|
const files = fs.readdirSync(projectPath).filter(f => !f.startsWith('.'))
|
|
47
72
|
if (files.length > 0) {
|
|
48
|
-
log.error('Current directory is not empty!
|
|
73
|
+
log.error('Current directory is not empty!')
|
|
49
74
|
process.exit(1)
|
|
50
75
|
}
|
|
51
76
|
}
|
|
52
77
|
|
|
78
|
+
// Ask for license key
|
|
79
|
+
console.log(colors.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
80
|
+
console.log(colors.yellow(' License Verification'))
|
|
81
|
+
console.log(colors.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
82
|
+
console.log('')
|
|
83
|
+
|
|
84
|
+
const keyResponse = await prompts({
|
|
85
|
+
type: 'password',
|
|
86
|
+
name: 'licenseKey',
|
|
87
|
+
message: 'Enter license key (or press Enter for basic version):',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const licenseKey = keyResponse.licenseKey?.trim() || ''
|
|
91
|
+
const isPremium = licenseKey.length > 0 && validateLicenseKey(licenseKey)
|
|
92
|
+
|
|
93
|
+
if (licenseKey.length > 0 && !isPremium) {
|
|
94
|
+
log.error('Invalid license key!')
|
|
95
|
+
console.log(colors.yellow('Continuing with basic version...'))
|
|
96
|
+
console.log('')
|
|
97
|
+
} else if (isPremium) {
|
|
98
|
+
log.success('License verified! Premium features unlocked.')
|
|
99
|
+
console.log('')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Database configuration (only for premium)
|
|
103
|
+
let dbConfig = null
|
|
104
|
+
if (isPremium) {
|
|
105
|
+
console.log(colors.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
106
|
+
console.log(colors.cyan(' Database Configuration'))
|
|
107
|
+
console.log(colors.cyan('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
108
|
+
console.log('')
|
|
109
|
+
|
|
110
|
+
dbConfig = await prompts([
|
|
111
|
+
{
|
|
112
|
+
type: 'select',
|
|
113
|
+
name: 'provider',
|
|
114
|
+
message: 'Database provider:',
|
|
115
|
+
choices: [
|
|
116
|
+
{ title: 'PostgreSQL (Recommended)', value: 'postgresql' },
|
|
117
|
+
{ title: 'MySQL', value: 'mysql' },
|
|
118
|
+
{ title: 'SQLite (Local dev)', value: 'sqlite' },
|
|
119
|
+
],
|
|
120
|
+
initial: 0,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: (prev) => prev !== 'sqlite' ? 'text' : null,
|
|
124
|
+
name: 'host',
|
|
125
|
+
message: 'Database host:',
|
|
126
|
+
initial: 'localhost',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: (prev, values) => values.provider !== 'sqlite' ? 'number' : null,
|
|
130
|
+
name: 'port',
|
|
131
|
+
message: 'Database port:',
|
|
132
|
+
initial: (prev, values) => values.provider === 'mysql' ? 3306 : 5432,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
type: (prev, values) => values.provider !== 'sqlite' ? 'text' : null,
|
|
136
|
+
name: 'database',
|
|
137
|
+
message: 'Database name:',
|
|
138
|
+
initial: 'admin_db',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
type: (prev, values) => values.provider !== 'sqlite' ? 'text' : null,
|
|
142
|
+
name: 'username',
|
|
143
|
+
message: 'Database username:',
|
|
144
|
+
initial: 'postgres',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
type: (prev, values) => values.provider !== 'sqlite' ? 'password' : null,
|
|
148
|
+
name: 'password',
|
|
149
|
+
message: 'Database password:',
|
|
150
|
+
},
|
|
151
|
+
])
|
|
152
|
+
console.log('')
|
|
153
|
+
}
|
|
154
|
+
|
|
53
155
|
try {
|
|
54
156
|
// Step 1: Create Next.js app
|
|
55
|
-
log.info('Creating Next.js
|
|
157
|
+
log.info('Creating Next.js application...')
|
|
56
158
|
execSync(
|
|
57
159
|
`npx create-next-app@latest ${projectName} --typescript --tailwind --eslint --app --src-dir=false --import-alias="@/*" --turbopack`,
|
|
58
160
|
{ stdio: 'inherit' }
|
|
59
161
|
)
|
|
60
162
|
log.success('Next.js app created')
|
|
61
163
|
|
|
62
|
-
//
|
|
164
|
+
// Navigate to project
|
|
63
165
|
process.chdir(projectPath)
|
|
64
166
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
log.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
167
|
+
if (isPremium) {
|
|
168
|
+
// Step 2: Install dependencies
|
|
169
|
+
log.info('Installing premium dependencies...')
|
|
170
|
+
execSync(
|
|
171
|
+
'npm install prisma @prisma/client next-auth@beta @auth/prisma-adapter bcryptjs zod react-hook-form @hookform/resolvers sonner',
|
|
172
|
+
{ stdio: 'inherit' }
|
|
173
|
+
)
|
|
174
|
+
execSync(
|
|
175
|
+
'npm install -D @types/bcryptjs',
|
|
176
|
+
{ stdio: 'inherit' }
|
|
177
|
+
)
|
|
178
|
+
log.success('Dependencies installed')
|
|
179
|
+
|
|
180
|
+
// Step 3: Install Shadcn components
|
|
181
|
+
log.info('Installing Shadcn UI components...')
|
|
182
|
+
execSync('npx shadcn@latest init -d', { stdio: 'inherit' })
|
|
183
|
+
execSync(
|
|
184
|
+
'npx shadcn@latest add button input label card form toast dialog dropdown-menu avatar badge table tabs separator alert alert-dialog checkbox select textarea switch skeleton sheet sidebar tooltip',
|
|
185
|
+
{ stdio: 'inherit' }
|
|
186
|
+
)
|
|
187
|
+
log.success('Shadcn components installed')
|
|
188
|
+
|
|
189
|
+
// Step 4: Initialize Prisma
|
|
190
|
+
log.info('Setting up Prisma...')
|
|
191
|
+
execSync(`npx prisma init --datasource-provider ${dbConfig.provider}`, { stdio: 'inherit' })
|
|
192
|
+
log.success('Prisma initialized')
|
|
193
|
+
|
|
194
|
+
// Step 5: Create .env file
|
|
195
|
+
log.info('Creating environment files...')
|
|
196
|
+
let databaseUrl = ''
|
|
197
|
+
if (dbConfig.provider === 'sqlite') {
|
|
198
|
+
databaseUrl = 'file:./dev.db'
|
|
199
|
+
} else if (dbConfig.provider === 'postgresql') {
|
|
200
|
+
databaseUrl = `postgresql://${dbConfig.username}:${dbConfig.password}@${dbConfig.host}:${dbConfig.port}/${dbConfig.database}?schema=public`
|
|
201
|
+
} else if (dbConfig.provider === 'mysql') {
|
|
202
|
+
databaseUrl = `mysql://${dbConfig.username}:${dbConfig.password}@${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fs.writeFileSync('.env', `# Database
|
|
206
|
+
DATABASE_URL="${databaseUrl}"
|
|
207
|
+
|
|
208
|
+
# NextAuth
|
|
209
|
+
AUTH_SECRET="${createHash('sha256').update(Date.now().toString()).digest('hex').slice(0, 32)}"
|
|
210
|
+
AUTH_TRUST_HOST=true
|
|
211
|
+
|
|
212
|
+
# App
|
|
213
|
+
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
214
|
+
`)
|
|
215
|
+
|
|
216
|
+
fs.writeFileSync('.env.example', `# Database
|
|
217
|
+
DATABASE_URL="postgresql://user:password@localhost:5432/admin_db?schema=public"
|
|
218
|
+
|
|
219
|
+
# NextAuth
|
|
220
|
+
AUTH_SECRET="your-secret-key-here"
|
|
221
|
+
AUTH_TRUST_HOST=true
|
|
222
|
+
|
|
223
|
+
# App
|
|
224
|
+
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
|
225
|
+
`)
|
|
226
|
+
log.success('Environment files created')
|
|
227
|
+
|
|
228
|
+
// Step 6: Create Prisma schema
|
|
229
|
+
log.info('Creating database schema...')
|
|
230
|
+
fs.writeFileSync('prisma/schema.prisma', `generator client {
|
|
231
|
+
provider = "prisma-client-js"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
datasource db {
|
|
235
|
+
provider = "${dbConfig.provider}"
|
|
236
|
+
url = env("DATABASE_URL")
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
enum Role {
|
|
240
|
+
USER
|
|
241
|
+
ADMIN
|
|
242
|
+
SUPER_ADMIN
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
model User {
|
|
246
|
+
id String @id @default(cuid())
|
|
247
|
+
name String?
|
|
248
|
+
email String @unique
|
|
249
|
+
emailVerified DateTime?
|
|
250
|
+
image String?
|
|
251
|
+
password String?
|
|
252
|
+
role Role @default(USER)
|
|
253
|
+
createdAt DateTime @default(now())
|
|
254
|
+
updatedAt DateTime @updatedAt
|
|
255
|
+
accounts Account[]
|
|
256
|
+
sessions Session[]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
model Account {
|
|
260
|
+
userId String
|
|
261
|
+
type String
|
|
262
|
+
provider String
|
|
263
|
+
providerAccountId String
|
|
264
|
+
refresh_token String?
|
|
265
|
+
access_token String?
|
|
266
|
+
expires_at Int?
|
|
267
|
+
token_type String?
|
|
268
|
+
scope String?
|
|
269
|
+
id_token String?
|
|
270
|
+
session_state String?
|
|
271
|
+
createdAt DateTime @default(now())
|
|
272
|
+
updatedAt DateTime @updatedAt
|
|
273
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
274
|
+
|
|
275
|
+
@@id([provider, providerAccountId])
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
model Session {
|
|
279
|
+
sessionToken String @unique
|
|
280
|
+
userId String
|
|
281
|
+
expires DateTime
|
|
282
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
283
|
+
createdAt DateTime @default(now())
|
|
284
|
+
updatedAt DateTime @updatedAt
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
model VerificationToken {
|
|
288
|
+
identifier String
|
|
289
|
+
token String
|
|
290
|
+
expires DateTime
|
|
291
|
+
|
|
292
|
+
@@id([identifier, token])
|
|
293
|
+
}
|
|
294
|
+
`)
|
|
295
|
+
log.success('Database schema created')
|
|
296
|
+
|
|
297
|
+
// Step 7: Create lib files
|
|
298
|
+
log.info('Creating library files...')
|
|
299
|
+
fs.mkdirSync('lib', { recursive: true })
|
|
300
|
+
|
|
301
|
+
// lib/db.ts
|
|
302
|
+
fs.writeFileSync('lib/db.ts', `import { PrismaClient } from '@prisma/client'
|
|
303
|
+
|
|
304
|
+
const globalForPrisma = globalThis as unknown as {
|
|
305
|
+
prisma: PrismaClient | undefined
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
|
309
|
+
|
|
310
|
+
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
|
311
|
+
|
|
312
|
+
export default prisma
|
|
313
|
+
`)
|
|
314
|
+
|
|
315
|
+
// lib/utils.ts (update existing)
|
|
316
|
+
const utilsContent = fs.readFileSync('lib/utils.ts', 'utf-8')
|
|
317
|
+
fs.writeFileSync('lib/utils.ts', utilsContent + `
|
|
318
|
+
|
|
319
|
+
export function formatDate(date: Date | string) {
|
|
320
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
321
|
+
month: 'short',
|
|
322
|
+
day: 'numeric',
|
|
323
|
+
year: 'numeric',
|
|
324
|
+
}).format(new Date(date))
|
|
325
|
+
}
|
|
326
|
+
`)
|
|
327
|
+
|
|
328
|
+
// lib/dal.ts - Data Access Layer
|
|
329
|
+
fs.writeFileSync('lib/dal.ts', `import { auth } from '@/auth'
|
|
330
|
+
import { redirect } from 'next/navigation'
|
|
331
|
+
import { cache } from 'react'
|
|
332
|
+
|
|
333
|
+
export const verifySession = cache(async () => {
|
|
334
|
+
const session = await auth()
|
|
335
|
+
|
|
336
|
+
if (!session?.user) {
|
|
337
|
+
redirect('/admin/login')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return session.user
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
export const verifyAdminSession = cache(async () => {
|
|
344
|
+
const session = await auth()
|
|
345
|
+
|
|
346
|
+
if (!session?.user) {
|
|
347
|
+
redirect('/admin/login')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const role = session.user.role
|
|
351
|
+
if (role !== 'ADMIN' && role !== 'SUPER_ADMIN') {
|
|
352
|
+
redirect('/unauthorized')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return session.user
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
export const getOptionalSession = cache(async () => {
|
|
359
|
+
const session = await auth()
|
|
360
|
+
return session?.user ?? null
|
|
361
|
+
})
|
|
362
|
+
`)
|
|
363
|
+
|
|
364
|
+
// lib/api/response.ts
|
|
365
|
+
fs.mkdirSync('lib/api', { recursive: true })
|
|
366
|
+
fs.writeFileSync('lib/api/response.ts', `import { NextResponse } from 'next/server'
|
|
367
|
+
|
|
368
|
+
interface ApiSuccessResponse<T> {
|
|
369
|
+
success: true
|
|
370
|
+
data: T
|
|
371
|
+
meta: {
|
|
372
|
+
timestamp: string
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface ApiErrorResponse {
|
|
377
|
+
success: false
|
|
378
|
+
error: {
|
|
379
|
+
code: string
|
|
380
|
+
message: string
|
|
381
|
+
details?: unknown
|
|
382
|
+
}
|
|
383
|
+
meta: {
|
|
384
|
+
timestamp: string
|
|
83
385
|
}
|
|
386
|
+
}
|
|
84
387
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// import { createDAL } from '@sks/admin-core/auth'
|
|
95
|
-
// import { auth } from '@/auth'
|
|
96
|
-
|
|
97
|
-
// export const {
|
|
98
|
-
// verifySession,
|
|
99
|
-
// verifyAdminSession,
|
|
100
|
-
// redirectIfAuthenticated,
|
|
101
|
-
// } = createDAL({
|
|
102
|
-
// auth,
|
|
103
|
-
// loginPath: '/admin/login',
|
|
104
|
-
// adminRoles: ['ADMIN'],
|
|
105
|
-
// })
|
|
106
|
-
|
|
107
|
-
// Placeholder exports for now
|
|
108
|
-
export const verifySession = async () => ({ userId: '1', email: 'test@test.com', role: 'ADMIN' })
|
|
109
|
-
export const verifyAdminSession = verifySession
|
|
110
|
-
`
|
|
388
|
+
export function apiSuccess<T>(data: T, status = 200): NextResponse<ApiSuccessResponse<T>> {
|
|
389
|
+
return NextResponse.json(
|
|
390
|
+
{
|
|
391
|
+
success: true,
|
|
392
|
+
data,
|
|
393
|
+
meta: { timestamp: new Date().toISOString() },
|
|
394
|
+
},
|
|
395
|
+
{ status }
|
|
111
396
|
)
|
|
397
|
+
}
|
|
112
398
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
399
|
+
export function apiError(
|
|
400
|
+
code: string,
|
|
401
|
+
message: string,
|
|
402
|
+
details?: unknown,
|
|
403
|
+
status = 500
|
|
404
|
+
): NextResponse<ApiErrorResponse> {
|
|
405
|
+
return NextResponse.json(
|
|
406
|
+
{
|
|
407
|
+
success: false,
|
|
408
|
+
error: { code, message, details },
|
|
409
|
+
meta: { timestamp: new Date().toISOString() },
|
|
410
|
+
},
|
|
411
|
+
{ status }
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export const ErrorCodes = {
|
|
416
|
+
UNAUTHORIZED: 'UNAUTHORIZED',
|
|
417
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
418
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
419
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
420
|
+
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
|
421
|
+
} as const
|
|
422
|
+
`)
|
|
423
|
+
|
|
424
|
+
// Step 8: Create auth.ts
|
|
425
|
+
log.info('Setting up authentication...')
|
|
426
|
+
fs.writeFileSync('auth.ts', `import NextAuth from 'next-auth'
|
|
427
|
+
import { PrismaAdapter } from '@auth/prisma-adapter'
|
|
428
|
+
import Credentials from 'next-auth/providers/credentials'
|
|
429
|
+
import bcrypt from 'bcryptjs'
|
|
430
|
+
import prisma from '@/lib/db'
|
|
431
|
+
|
|
432
|
+
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
433
|
+
adapter: PrismaAdapter(prisma),
|
|
434
|
+
session: { strategy: 'jwt' },
|
|
435
|
+
pages: {
|
|
436
|
+
signIn: '/admin/login',
|
|
437
|
+
},
|
|
438
|
+
providers: [
|
|
439
|
+
Credentials({
|
|
440
|
+
name: 'credentials',
|
|
441
|
+
credentials: {
|
|
442
|
+
email: { label: 'Email', type: 'email' },
|
|
443
|
+
password: { label: 'Password', type: 'password' },
|
|
444
|
+
},
|
|
445
|
+
async authorize(credentials) {
|
|
446
|
+
if (!credentials?.email || !credentials?.password) {
|
|
447
|
+
return null
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const user = await prisma.user.findUnique({
|
|
451
|
+
where: { email: credentials.email as string },
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
if (!user || !user.password) {
|
|
455
|
+
return null
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const passwordMatch = await bcrypt.compare(
|
|
459
|
+
credentials.password as string,
|
|
460
|
+
user.password
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if (!passwordMatch) {
|
|
464
|
+
return null
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
id: user.id,
|
|
469
|
+
email: user.email,
|
|
470
|
+
name: user.name,
|
|
471
|
+
role: user.role,
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
}),
|
|
475
|
+
],
|
|
476
|
+
callbacks: {
|
|
477
|
+
async jwt({ token, user }) {
|
|
478
|
+
if (user) {
|
|
479
|
+
token.role = user.role
|
|
480
|
+
}
|
|
481
|
+
return token
|
|
482
|
+
},
|
|
483
|
+
async session({ session, token }) {
|
|
484
|
+
if (session.user) {
|
|
485
|
+
session.user.id = token.sub!
|
|
486
|
+
session.user.role = token.role as string
|
|
487
|
+
}
|
|
488
|
+
return session
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
})
|
|
492
|
+
`)
|
|
493
|
+
|
|
494
|
+
// types/next-auth.d.ts
|
|
495
|
+
fs.mkdirSync('types', { recursive: true })
|
|
496
|
+
fs.writeFileSync('types/next-auth.d.ts', `import { DefaultSession } from 'next-auth'
|
|
497
|
+
|
|
498
|
+
declare module 'next-auth' {
|
|
499
|
+
interface Session {
|
|
500
|
+
user: {
|
|
501
|
+
id: string
|
|
502
|
+
role: string
|
|
503
|
+
} & DefaultSession['user']
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface User {
|
|
507
|
+
role: string
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
declare module 'next-auth/jwt' {
|
|
512
|
+
interface JWT {
|
|
513
|
+
role?: string
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
`)
|
|
517
|
+
|
|
518
|
+
// Step 9: Create API route for auth
|
|
519
|
+
fs.mkdirSync('app/api/auth/[...nextauth]', { recursive: true })
|
|
520
|
+
fs.writeFileSync('app/api/auth/[...nextauth]/route.ts', `import { handlers } from '@/auth'
|
|
521
|
+
|
|
522
|
+
export const { GET, POST } = handlers
|
|
523
|
+
`)
|
|
524
|
+
|
|
525
|
+
// Step 10: Create admin pages
|
|
526
|
+
log.info('Creating admin pages...')
|
|
119
527
|
|
|
120
|
-
//
|
|
121
|
-
|
|
528
|
+
// Admin layout
|
|
529
|
+
fs.mkdirSync('app/admin', { recursive: true })
|
|
530
|
+
fs.writeFileSync('app/admin/layout.tsx', `import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
|
531
|
+
import { AppSidebar } from '@/components/app-sidebar'
|
|
532
|
+
import { Toaster } from '@/components/ui/sonner'
|
|
122
533
|
|
|
123
534
|
export default function AdminLayout({
|
|
124
535
|
children,
|
|
@@ -126,70 +537,344 @@ export default function AdminLayout({
|
|
|
126
537
|
children: React.ReactNode
|
|
127
538
|
}) {
|
|
128
539
|
return (
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
</
|
|
138
|
-
</aside>
|
|
139
|
-
<main className="ml-64 p-8">
|
|
140
|
-
{children}
|
|
540
|
+
<SidebarProvider>
|
|
541
|
+
<AppSidebar />
|
|
542
|
+
<main className="flex-1">
|
|
543
|
+
<header className="flex h-14 items-center gap-4 border-b bg-background px-6">
|
|
544
|
+
<SidebarTrigger />
|
|
545
|
+
</header>
|
|
546
|
+
<div className="p-6">
|
|
547
|
+
{children}
|
|
548
|
+
</div>
|
|
141
549
|
</main>
|
|
142
|
-
|
|
550
|
+
<Toaster />
|
|
551
|
+
</SidebarProvider>
|
|
143
552
|
)
|
|
144
553
|
}
|
|
145
|
-
`
|
|
146
|
-
|
|
554
|
+
`)
|
|
555
|
+
|
|
556
|
+
// Admin dashboard
|
|
557
|
+
fs.writeFileSync('app/admin/page.tsx', `import { verifyAdminSession } from '@/lib/dal'
|
|
558
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
559
|
+
|
|
560
|
+
export default async function AdminDashboard() {
|
|
561
|
+
const user = await verifyAdminSession()
|
|
147
562
|
|
|
148
|
-
// Step 7: Create admin dashboard page
|
|
149
|
-
fs.writeFileSync(
|
|
150
|
-
'app/admin/page.tsx',
|
|
151
|
-
`export default function AdminDashboard() {
|
|
152
563
|
return (
|
|
153
|
-
<div>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
<
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
564
|
+
<div className="space-y-6">
|
|
565
|
+
<div>
|
|
566
|
+
<h1 className="text-3xl font-bold">Dashboard</h1>
|
|
567
|
+
<p className="text-muted-foreground">Welcome back, {user.name || user.email}</p>
|
|
568
|
+
</div>
|
|
569
|
+
|
|
570
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
571
|
+
<Card>
|
|
572
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
573
|
+
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
|
574
|
+
</CardHeader>
|
|
575
|
+
<CardContent>
|
|
576
|
+
<div className="text-2xl font-bold">1,234</div>
|
|
577
|
+
<p className="text-xs text-muted-foreground">+12% from last month</p>
|
|
578
|
+
</CardContent>
|
|
579
|
+
</Card>
|
|
580
|
+
<Card>
|
|
581
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
582
|
+
<CardTitle className="text-sm font-medium">Revenue</CardTitle>
|
|
583
|
+
</CardHeader>
|
|
584
|
+
<CardContent>
|
|
585
|
+
<div className="text-2xl font-bold">$12,345</div>
|
|
586
|
+
<p className="text-xs text-muted-foreground">+8% from last month</p>
|
|
587
|
+
</CardContent>
|
|
588
|
+
</Card>
|
|
589
|
+
<Card>
|
|
590
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
591
|
+
<CardTitle className="text-sm font-medium">Active Sessions</CardTitle>
|
|
592
|
+
</CardHeader>
|
|
593
|
+
<CardContent>
|
|
594
|
+
<div className="text-2xl font-bold">573</div>
|
|
595
|
+
<p className="text-xs text-muted-foreground">+2% from last hour</p>
|
|
596
|
+
</CardContent>
|
|
597
|
+
</Card>
|
|
598
|
+
<Card>
|
|
599
|
+
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
600
|
+
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
|
|
601
|
+
</CardHeader>
|
|
602
|
+
<CardContent>
|
|
603
|
+
<div className="text-2xl font-bold">3.2%</div>
|
|
604
|
+
<p className="text-xs text-muted-foreground">+0.5% from last week</p>
|
|
605
|
+
</CardContent>
|
|
606
|
+
</Card>
|
|
168
607
|
</div>
|
|
169
608
|
</div>
|
|
170
609
|
)
|
|
171
610
|
}
|
|
172
|
-
`
|
|
611
|
+
`)
|
|
612
|
+
|
|
613
|
+
// Login page
|
|
614
|
+
fs.mkdirSync('app/admin/login', { recursive: true })
|
|
615
|
+
fs.writeFileSync('app/admin/login/page.tsx', `'use client'
|
|
616
|
+
|
|
617
|
+
import { useState } from 'react'
|
|
618
|
+
import { signIn } from 'next-auth/react'
|
|
619
|
+
import { useRouter } from 'next/navigation'
|
|
620
|
+
import { Button } from '@/components/ui/button'
|
|
621
|
+
import { Input } from '@/components/ui/input'
|
|
622
|
+
import { Label } from '@/components/ui/label'
|
|
623
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
624
|
+
|
|
625
|
+
export default function LoginPage() {
|
|
626
|
+
const [email, setEmail] = useState('')
|
|
627
|
+
const [password, setPassword] = useState('')
|
|
628
|
+
const [error, setError] = useState('')
|
|
629
|
+
const [loading, setLoading] = useState(false)
|
|
630
|
+
const router = useRouter()
|
|
631
|
+
|
|
632
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
633
|
+
e.preventDefault()
|
|
634
|
+
setLoading(true)
|
|
635
|
+
setError('')
|
|
636
|
+
|
|
637
|
+
const result = await signIn('credentials', {
|
|
638
|
+
email,
|
|
639
|
+
password,
|
|
640
|
+
redirect: false,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
if (result?.error) {
|
|
644
|
+
setError('Invalid email or password')
|
|
645
|
+
setLoading(false)
|
|
646
|
+
} else {
|
|
647
|
+
router.push('/admin')
|
|
648
|
+
router.refresh()
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
|
654
|
+
<Card className="w-full max-w-md">
|
|
655
|
+
<CardHeader className="text-center">
|
|
656
|
+
<CardTitle className="text-2xl">Admin Login</CardTitle>
|
|
657
|
+
<CardDescription>Enter your credentials to continue</CardDescription>
|
|
658
|
+
</CardHeader>
|
|
659
|
+
<CardContent>
|
|
660
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
661
|
+
{error && (
|
|
662
|
+
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
|
|
663
|
+
{error}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
<div className="space-y-2">
|
|
667
|
+
<Label htmlFor="email">Email</Label>
|
|
668
|
+
<Input
|
|
669
|
+
id="email"
|
|
670
|
+
type="email"
|
|
671
|
+
placeholder="admin@example.com"
|
|
672
|
+
value={email}
|
|
673
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
674
|
+
required
|
|
675
|
+
/>
|
|
676
|
+
</div>
|
|
677
|
+
<div className="space-y-2">
|
|
678
|
+
<Label htmlFor="password">Password</Label>
|
|
679
|
+
<Input
|
|
680
|
+
id="password"
|
|
681
|
+
type="password"
|
|
682
|
+
value={password}
|
|
683
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
684
|
+
required
|
|
685
|
+
/>
|
|
686
|
+
</div>
|
|
687
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
688
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
689
|
+
</Button>
|
|
690
|
+
</form>
|
|
691
|
+
</CardContent>
|
|
692
|
+
</Card>
|
|
693
|
+
</div>
|
|
173
694
|
)
|
|
695
|
+
}
|
|
696
|
+
`)
|
|
174
697
|
|
|
175
|
-
|
|
698
|
+
// Sidebar component
|
|
699
|
+
fs.mkdirSync('components', { recursive: true })
|
|
700
|
+
fs.writeFileSync('components/app-sidebar.tsx', `'use client'
|
|
176
701
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
702
|
+
import {
|
|
703
|
+
Sidebar,
|
|
704
|
+
SidebarContent,
|
|
705
|
+
SidebarFooter,
|
|
706
|
+
SidebarGroup,
|
|
707
|
+
SidebarGroupContent,
|
|
708
|
+
SidebarGroupLabel,
|
|
709
|
+
SidebarHeader,
|
|
710
|
+
SidebarMenu,
|
|
711
|
+
SidebarMenuButton,
|
|
712
|
+
SidebarMenuItem,
|
|
713
|
+
} from '@/components/ui/sidebar'
|
|
714
|
+
import { Home, Users, Settings, LogOut } from 'lucide-react'
|
|
715
|
+
import { signOut } from 'next-auth/react'
|
|
716
|
+
import Link from 'next/link'
|
|
180
717
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
718
|
+
const menuItems = [
|
|
719
|
+
{ title: 'Dashboard', icon: Home, href: '/admin' },
|
|
720
|
+
{ title: 'Users', icon: Users, href: '/admin/users' },
|
|
721
|
+
{ title: 'Settings', icon: Settings, href: '/admin/settings' },
|
|
722
|
+
]
|
|
184
723
|
|
|
185
|
-
|
|
186
|
-
|
|
724
|
+
export function AppSidebar() {
|
|
725
|
+
return (
|
|
726
|
+
<Sidebar>
|
|
727
|
+
<SidebarHeader className="border-b px-6 py-4">
|
|
728
|
+
<h1 className="text-xl font-bold">Admin Panel</h1>
|
|
729
|
+
</SidebarHeader>
|
|
730
|
+
<SidebarContent>
|
|
731
|
+
<SidebarGroup>
|
|
732
|
+
<SidebarGroupLabel>Menu</SidebarGroupLabel>
|
|
733
|
+
<SidebarGroupContent>
|
|
734
|
+
<SidebarMenu>
|
|
735
|
+
{menuItems.map((item) => (
|
|
736
|
+
<SidebarMenuItem key={item.title}>
|
|
737
|
+
<SidebarMenuButton asChild>
|
|
738
|
+
<Link href={item.href}>
|
|
739
|
+
<item.icon className="h-4 w-4" />
|
|
740
|
+
<span>{item.title}</span>
|
|
741
|
+
</Link>
|
|
742
|
+
</SidebarMenuButton>
|
|
743
|
+
</SidebarMenuItem>
|
|
744
|
+
))}
|
|
745
|
+
</SidebarMenu>
|
|
746
|
+
</SidebarGroupContent>
|
|
747
|
+
</SidebarGroup>
|
|
748
|
+
</SidebarContent>
|
|
749
|
+
<SidebarFooter className="border-t p-4">
|
|
750
|
+
<SidebarMenu>
|
|
751
|
+
<SidebarMenuItem>
|
|
752
|
+
<SidebarMenuButton onClick={() => signOut({ callbackUrl: '/admin/login' })}>
|
|
753
|
+
<LogOut className="h-4 w-4" />
|
|
754
|
+
<span>Logout</span>
|
|
755
|
+
</SidebarMenuButton>
|
|
756
|
+
</SidebarMenuItem>
|
|
757
|
+
</SidebarMenu>
|
|
758
|
+
</SidebarFooter>
|
|
759
|
+
</Sidebar>
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
`)
|
|
763
|
+
|
|
764
|
+
// Install lucide-react
|
|
765
|
+
execSync('npm install lucide-react', { stdio: 'inherit' })
|
|
187
766
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
767
|
+
// Create seed script
|
|
768
|
+
fs.mkdirSync('prisma', { recursive: true })
|
|
769
|
+
fs.writeFileSync('prisma/seed.ts', `import { PrismaClient } from '@prisma/client'
|
|
770
|
+
import bcrypt from 'bcryptjs'
|
|
771
|
+
|
|
772
|
+
const prisma = new PrismaClient()
|
|
773
|
+
|
|
774
|
+
async function main() {
|
|
775
|
+
const hashedPassword = await bcrypt.hash('admin123', 10)
|
|
776
|
+
|
|
777
|
+
const admin = await prisma.user.upsert({
|
|
778
|
+
where: { email: 'admin@example.com' },
|
|
779
|
+
update: {},
|
|
780
|
+
create: {
|
|
781
|
+
email: 'admin@example.com',
|
|
782
|
+
name: 'Admin User',
|
|
783
|
+
password: hashedPassword,
|
|
784
|
+
role: 'ADMIN',
|
|
785
|
+
},
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
console.log('Created admin user:', admin.email)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
main()
|
|
792
|
+
.catch((e) => {
|
|
793
|
+
console.error(e)
|
|
794
|
+
process.exit(1)
|
|
795
|
+
})
|
|
796
|
+
.finally(async () => {
|
|
797
|
+
await prisma.$disconnect()
|
|
798
|
+
})
|
|
191
799
|
`)
|
|
192
800
|
|
|
801
|
+
// Update package.json with prisma seed
|
|
802
|
+
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
|
|
803
|
+
packageJson.prisma = { seed: 'npx tsx prisma/seed.ts' }
|
|
804
|
+
fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2))
|
|
805
|
+
|
|
806
|
+
execSync('npm install -D tsx', { stdio: 'inherit' })
|
|
807
|
+
|
|
808
|
+
log.success('Admin pages created')
|
|
809
|
+
} else {
|
|
810
|
+
// Basic version - just create simple admin page
|
|
811
|
+
log.info('Creating basic admin template...')
|
|
812
|
+
fs.mkdirSync('app/admin', { recursive: true })
|
|
813
|
+
fs.writeFileSync('app/admin/page.tsx', `export default function AdminPage() {
|
|
814
|
+
return (
|
|
815
|
+
<div className="min-h-screen bg-gray-100 p-8">
|
|
816
|
+
<div className="max-w-4xl mx-auto">
|
|
817
|
+
<h1 className="text-3xl font-bold mb-4">Admin Panel</h1>
|
|
818
|
+
<p className="text-gray-600 mb-8">Basic version - upgrade to premium for full features.</p>
|
|
819
|
+
|
|
820
|
+
<div className="grid grid-cols-3 gap-4">
|
|
821
|
+
<div className="bg-white p-6 rounded-lg shadow">
|
|
822
|
+
<h2 className="text-gray-500 text-sm">Total Users</h2>
|
|
823
|
+
<p className="text-3xl font-bold">--</p>
|
|
824
|
+
</div>
|
|
825
|
+
<div className="bg-white p-6 rounded-lg shadow">
|
|
826
|
+
<h2 className="text-gray-500 text-sm">Revenue</h2>
|
|
827
|
+
<p className="text-3xl font-bold">--</p>
|
|
828
|
+
</div>
|
|
829
|
+
<div className="bg-white p-6 rounded-lg shadow">
|
|
830
|
+
<h2 className="text-gray-500 text-sm">Orders</h2>
|
|
831
|
+
<p className="text-3xl font-bold">--</p>
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
<div className="mt-8 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
|
836
|
+
<p className="text-yellow-800">
|
|
837
|
+
<strong>Upgrade to Premium</strong> to unlock: Prisma ORM, NextAuth, Shadcn UI,
|
|
838
|
+
Admin Dashboard, User Management, and more!
|
|
839
|
+
</p>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
`)
|
|
846
|
+
log.success('Basic template created')
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Final message
|
|
850
|
+
console.log('')
|
|
851
|
+
console.log(colors.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
852
|
+
console.log(colors.green(' ✓ Project created successfully!'))
|
|
853
|
+
console.log(colors.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
854
|
+
console.log('')
|
|
855
|
+
|
|
856
|
+
if (isPremium) {
|
|
857
|
+
console.log(colors.cyan('Next steps:'))
|
|
858
|
+
console.log(` 1. cd ${isCurrentDir ? '.' : projectName}`)
|
|
859
|
+
console.log(' 2. npx prisma db push # Create database tables')
|
|
860
|
+
console.log(' 3. npx prisma db seed # Create admin user')
|
|
861
|
+
console.log(' 4. npm run dev # Start development server')
|
|
862
|
+
console.log('')
|
|
863
|
+
console.log(colors.cyan('Default admin credentials:'))
|
|
864
|
+
console.log(' Email: admin@example.com')
|
|
865
|
+
console.log(' Password: admin123')
|
|
866
|
+
console.log('')
|
|
867
|
+
console.log(colors.cyan('Admin panel:'))
|
|
868
|
+
console.log(' http://localhost:3000/admin')
|
|
869
|
+
} else {
|
|
870
|
+
console.log(colors.blue('Next steps:'))
|
|
871
|
+
console.log(` cd ${isCurrentDir ? '.' : projectName}`)
|
|
872
|
+
console.log(' npm run dev')
|
|
873
|
+
console.log('')
|
|
874
|
+
console.log(colors.yellow('Get premium license for full features!'))
|
|
875
|
+
}
|
|
876
|
+
console.log('')
|
|
877
|
+
|
|
193
878
|
} catch (error) {
|
|
194
879
|
log.error('Failed to create project')
|
|
195
880
|
console.error(error)
|