create-nara 1.0.35 → 1.0.36
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.
Potentially problematic release.
This version of create-nara might be problematic. Click here for more details.
- package/dist/template.js +3 -0
- package/package.json +1 -1
- package/templates/features/auth/app/controllers/AuthController.ts +27 -40
- package/templates/features/auth/app/controllers/ProfileController.ts +23 -23
- package/templates/vue/app/services/View.ts +71 -0
- package/templates/vue/postcss.config.js +5 -0
- package/templates/vue/resources/js/components/NaraIcon.vue +1 -1
- package/templates/vue/routes/web.ts +28 -5
- package/templates/vue/server.ts +2 -1
package/dist/template.js
CHANGED
|
@@ -102,6 +102,9 @@ function createPackageJson(name, mode, features) {
|
|
|
102
102
|
pkg.devDependencies['vite'] = '^6.0.0';
|
|
103
103
|
pkg.devDependencies['@vitejs/plugin-vue'] = '^5.0.0';
|
|
104
104
|
pkg.devDependencies['concurrently'] = '^9.0.0';
|
|
105
|
+
pkg.devDependencies['tailwindcss'] = '^4.0.0';
|
|
106
|
+
pkg.devDependencies['@tailwindcss/postcss'] = '^4.0.0';
|
|
107
|
+
pkg.devDependencies['autoprefixer'] = '^10.4.20';
|
|
105
108
|
}
|
|
106
109
|
if (features.includes('db')) {
|
|
107
110
|
pkg.dependencies['knex'] = '^3.1.0';
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseController, jsonSuccess, jsonError
|
|
1
|
+
import { BaseController, jsonSuccess, jsonError } from '@nara-web/core';
|
|
2
2
|
import type { NaraRequest, NaraResponse } from '@nara-web/core';
|
|
3
3
|
import { UserModel } from '../models/User.js';
|
|
4
4
|
import bcrypt from 'bcrypt';
|
|
@@ -20,13 +20,16 @@ export class AuthController extends BaseController {
|
|
|
20
20
|
const { email, password } = await req.json();
|
|
21
21
|
|
|
22
22
|
if (!email || !password) {
|
|
23
|
-
|
|
23
|
+
// Set error cookie and redirect back (Inertia pattern)
|
|
24
|
+
res.cookie('error', 'Email and password are required', 5000);
|
|
25
|
+
return res.redirect('/login');
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
// Find user by email
|
|
27
29
|
const user = await UserModel.findByEmail(email);
|
|
28
30
|
if (!user || !await bcrypt.compare(password, user.password)) {
|
|
29
|
-
|
|
31
|
+
res.cookie('error', 'Invalid credentials', 5000);
|
|
32
|
+
return res.redirect('/login');
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
// Generate JWT token with user info
|
|
@@ -39,27 +42,23 @@ export class AuthController extends BaseController {
|
|
|
39
42
|
// Set auth cookie for web routes (maxAge in ms)
|
|
40
43
|
res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
redirect: '/dashboard'
|
|
45
|
-
}, 'Login successful');
|
|
45
|
+
// Redirect to dashboard (Inertia will handle it)
|
|
46
|
+
return res.redirect('/dashboard');
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
async register(req: NaraRequest, res: NaraResponse) {
|
|
49
50
|
const { name, email, password } = await req.json();
|
|
50
51
|
|
|
51
52
|
if (!name || !email || !password) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
email: !email ? ['Email is required'] : [],
|
|
55
|
-
password: !password ? ['Password is required'] : [],
|
|
56
|
-
});
|
|
53
|
+
res.cookie('error', 'Name, email and password are required', 5000);
|
|
54
|
+
return res.redirect('/register');
|
|
57
55
|
}
|
|
58
56
|
|
|
59
57
|
// Check if email already exists
|
|
60
58
|
const existing = await UserModel.findByEmail(email);
|
|
61
59
|
if (existing) {
|
|
62
|
-
|
|
60
|
+
res.cookie('error', 'Email already registered', 5000);
|
|
61
|
+
return res.redirect('/register');
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
// Hash password
|
|
@@ -78,10 +77,8 @@ export class AuthController extends BaseController {
|
|
|
78
77
|
// Set auth cookie for web routes (maxAge in ms)
|
|
79
78
|
res.cookie('auth_token', token, JWT_EXPIRES_SECONDS * 1000, COOKIE_OPTIONS);
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
redirect: '/dashboard'
|
|
84
|
-
}, 'Registration successful');
|
|
80
|
+
// Redirect to dashboard
|
|
81
|
+
return res.redirect('/dashboard');
|
|
85
82
|
}
|
|
86
83
|
|
|
87
84
|
async me(req: NaraRequest, res: NaraResponse) {
|
|
@@ -98,46 +95,36 @@ export class AuthController extends BaseController {
|
|
|
98
95
|
// Clear auth cookie (set maxAge to 0)
|
|
99
96
|
res.cookie('auth_token', '', 0, COOKIE_OPTIONS);
|
|
100
97
|
|
|
101
|
-
|
|
98
|
+
// Redirect to login
|
|
99
|
+
return res.redirect('/login');
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
async forgotPassword(req: NaraRequest, res: NaraResponse) {
|
|
105
103
|
const { email } = await req.json();
|
|
106
104
|
|
|
107
105
|
if (!email) {
|
|
108
|
-
|
|
106
|
+
res.cookie('error', 'Email is required', 5000);
|
|
107
|
+
return res.redirect('/forgot-password');
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
// TODO: Implement actual password reset email sending
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// }
|
|
117
|
-
|
|
118
|
-
// Always return success to prevent email enumeration
|
|
119
|
-
return jsonSuccess(res, {}, 'If an account exists with this email, a reset link has been sent.');
|
|
111
|
+
|
|
112
|
+
// Set success message and redirect
|
|
113
|
+
res.cookie('success', 'If an account exists with this email, a reset link has been sent.', 5000);
|
|
114
|
+
return res.redirect('/login');
|
|
120
115
|
}
|
|
121
116
|
|
|
122
117
|
async resetPassword(req: NaraRequest, res: NaraResponse) {
|
|
123
118
|
const { token, password } = await req.json();
|
|
124
119
|
|
|
125
120
|
if (!token || !password) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
password: !password ? ['Password is required'] : [],
|
|
129
|
-
});
|
|
121
|
+
res.cookie('error', 'Reset token and password are required', 5000);
|
|
122
|
+
return res.redirect('/forgot-password');
|
|
130
123
|
}
|
|
131
124
|
|
|
132
125
|
// TODO: Implement actual password reset
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// }
|
|
137
|
-
// const hashedPassword = await bcrypt.hash(password, 10);
|
|
138
|
-
// await UserModel.updatePassword(resetRecord.userId, hashedPassword);
|
|
139
|
-
// await PasswordReset.delete(token);
|
|
140
|
-
|
|
141
|
-
return jsonSuccess(res, { redirect: '/login' }, 'Password has been reset successfully.');
|
|
126
|
+
|
|
127
|
+
res.cookie('success', 'Password has been reset successfully.', 5000);
|
|
128
|
+
return res.redirect('/login');
|
|
142
129
|
}
|
|
143
130
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseController, jsonSuccess, jsonError
|
|
1
|
+
import { BaseController, jsonSuccess, jsonError } from '@nara-web/core';
|
|
2
2
|
import type { NaraRequest, NaraResponse } from '@nara-web/core';
|
|
3
3
|
import { UserModel } from '../models/User.js';
|
|
4
4
|
import bcrypt from 'bcrypt';
|
|
@@ -21,62 +21,58 @@ export class ProfileController extends BaseController {
|
|
|
21
21
|
async update(req: NaraRequest, res: NaraResponse) {
|
|
22
22
|
const user = req.user;
|
|
23
23
|
if (!user) {
|
|
24
|
-
return res.
|
|
24
|
+
return res.redirect('/login');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const { name, email, phone } = await req.json();
|
|
28
28
|
|
|
29
29
|
if (!name || !email) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
email: !email ? ['Email is required'] : [],
|
|
33
|
-
});
|
|
30
|
+
res.cookie('error', 'Name and email are required', 5000);
|
|
31
|
+
return res.redirect('/profile');
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
// Update user in database
|
|
37
35
|
await UserModel.update(user.id, { name, email, phone });
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}, 'Profile updated successfully');
|
|
37
|
+
res.cookie('success', 'Profile updated successfully', 5000);
|
|
38
|
+
return res.redirect('/profile');
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
async changePassword(req: NaraRequest, res: NaraResponse) {
|
|
45
42
|
const user = req.user;
|
|
46
43
|
if (!user) {
|
|
47
|
-
return res.
|
|
44
|
+
return res.redirect('/login');
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
const { current_password, new_password } = await req.json();
|
|
51
48
|
|
|
52
49
|
if (!current_password || !new_password) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
new_password: !new_password ? ['New password is required'] : [],
|
|
56
|
-
});
|
|
50
|
+
res.cookie('error', 'Current password and new password are required', 5000);
|
|
51
|
+
return res.redirect('/profile');
|
|
57
52
|
}
|
|
58
53
|
|
|
59
54
|
if (new_password.length < 6) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
});
|
|
55
|
+
res.cookie('error', 'Password must be at least 6 characters', 5000);
|
|
56
|
+
return res.redirect('/profile');
|
|
63
57
|
}
|
|
64
58
|
|
|
65
59
|
// Verify current password and update
|
|
66
60
|
const dbUser = await UserModel.findById(user.id);
|
|
67
61
|
if (!dbUser || !await bcrypt.compare(current_password, dbUser.password)) {
|
|
68
|
-
|
|
62
|
+
res.cookie('error', 'Current password is incorrect', 5000);
|
|
63
|
+
return res.redirect('/profile');
|
|
69
64
|
}
|
|
70
65
|
const hashedPassword = await bcrypt.hash(new_password, 10);
|
|
71
66
|
await UserModel.update(user.id, { password: hashedPassword });
|
|
72
67
|
|
|
73
|
-
|
|
68
|
+
res.cookie('success', 'Password changed successfully', 5000);
|
|
69
|
+
return res.redirect('/profile');
|
|
74
70
|
}
|
|
75
71
|
|
|
76
72
|
async uploadAvatar(req: NaraRequest, res: NaraResponse) {
|
|
77
73
|
const user = req.user;
|
|
78
74
|
if (!user) {
|
|
79
|
-
return res.
|
|
75
|
+
return res.redirect('/login');
|
|
80
76
|
}
|
|
81
77
|
|
|
82
78
|
try {
|
|
@@ -84,13 +80,15 @@ export class ProfileController extends BaseController {
|
|
|
84
80
|
const buffer = await req.buffer();
|
|
85
81
|
|
|
86
82
|
if (!buffer || buffer.length === 0) {
|
|
87
|
-
|
|
83
|
+
res.cookie('error', 'No file uploaded', 5000);
|
|
84
|
+
return res.redirect('/profile');
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
// Extract image from multipart form data
|
|
91
88
|
const boundary = req.headers['content-type']?.split('boundary=')[1];
|
|
92
89
|
if (!boundary) {
|
|
93
|
-
|
|
90
|
+
res.cookie('error', 'Invalid multipart form data', 5000);
|
|
91
|
+
return res.redirect('/profile');
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
// Find image data in multipart
|
|
@@ -109,7 +107,8 @@ export class ProfileController extends BaseController {
|
|
|
109
107
|
}
|
|
110
108
|
|
|
111
109
|
if (!imageBuffer) {
|
|
112
|
-
|
|
110
|
+
res.cookie('error', 'No image found in upload', 5000);
|
|
111
|
+
return res.redirect('/profile');
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
// Generate unique filename
|
|
@@ -127,6 +126,7 @@ export class ProfileController extends BaseController {
|
|
|
127
126
|
// Update user avatar in database
|
|
128
127
|
await UserModel.update(user.id, { avatar: avatarUrl });
|
|
129
128
|
|
|
129
|
+
// Return JSON for AJAX upload (not redirect)
|
|
130
130
|
return jsonSuccess(res, { url: avatarUrl }, 'Avatar uploaded successfully');
|
|
131
131
|
} catch (error) {
|
|
132
132
|
console.error('Avatar upload error:', error);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const templateCache: { [key: string]: string } = {};
|
|
5
|
+
|
|
6
|
+
function getViewsDirectory() {
|
|
7
|
+
return process.env.NODE_ENV === 'development' ? "resources/views" : "dist/views";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function loadTemplate(filename: string): string {
|
|
11
|
+
const directory = getViewsDirectory();
|
|
12
|
+
const key = path.join(directory, filename);
|
|
13
|
+
if (!templateCache[key]) {
|
|
14
|
+
templateCache[key] = readFileSync(key, "utf8");
|
|
15
|
+
}
|
|
16
|
+
return templateCache[key];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function escapeHtml(value: string): string {
|
|
20
|
+
return value
|
|
21
|
+
.replace(/&/g, "&")
|
|
22
|
+
.replace(/</g, "<")
|
|
23
|
+
.replace(/>/g, ">")
|
|
24
|
+
.replace(/\"/g, """)
|
|
25
|
+
.replace(/'/g, "'");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getViteScripts(): string {
|
|
29
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
30
|
+
if (isDev) {
|
|
31
|
+
return `
|
|
32
|
+
<script type="module" src="http://localhost:5173/@vite/client"></script>
|
|
33
|
+
<script type="module" src="http://localhost:5173/resources/js/app.ts"></script>
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
// For production, read manifest and generate script tags
|
|
37
|
+
try {
|
|
38
|
+
const manifest = JSON.parse(readFileSync('public/build/.vite/manifest.json', 'utf8'));
|
|
39
|
+
const entry = manifest['resources/js/app.ts'];
|
|
40
|
+
if (entry) {
|
|
41
|
+
return `<script type="module" src="/build/${entry.file}"></script>`;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Fallback
|
|
45
|
+
}
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function view(filename: string, view_data?: any) {
|
|
50
|
+
const baseTemplate = loadTemplate(filename);
|
|
51
|
+
let html = baseTemplate;
|
|
52
|
+
|
|
53
|
+
// Replace @vite placeholder with Vite scripts
|
|
54
|
+
html = html.replace('@vite', getViteScripts());
|
|
55
|
+
|
|
56
|
+
// Replace @inertia placeholder with inertia root div
|
|
57
|
+
if (view_data?.page) {
|
|
58
|
+
html = html.replace('@inertia', `<div id="app" data-page='${view_data.page}'></div>`);
|
|
59
|
+
} else {
|
|
60
|
+
html = html.replace('@inertia', '<div id="app"></div>');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Handle other template variables
|
|
64
|
+
if (view_data) {
|
|
65
|
+
if (typeof view_data.title === "string") {
|
|
66
|
+
html = html.replace("{{it.title}}", escapeHtml(view_data.title));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return html;
|
|
71
|
+
}
|
|
@@ -25,7 +25,7 @@ export function registerRoutes(app: NaraApp) {
|
|
|
25
25
|
(res as InertiaResponse).inertia('landing', { title: 'Welcome to NARA' });
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
// Guest only
|
|
28
|
+
// Guest only - Pages
|
|
29
29
|
app.get('/login', guestMiddleware as any, (req, res: any) => {
|
|
30
30
|
(res as InertiaResponse).inertia('auth/login');
|
|
31
31
|
});
|
|
@@ -39,7 +39,14 @@ export function registerRoutes(app: NaraApp) {
|
|
|
39
39
|
(res as InertiaResponse).inertia('auth/reset-password', { token: req.params.token });
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Auth Form Actions (same path as pages - for Inertia form handling)
|
|
43
|
+
app.post('/login', wrapHandler((req, res) => auth.login(req, res)));
|
|
44
|
+
app.post('/register', wrapHandler((req, res) => auth.register(req, res)));
|
|
45
|
+
app.post('/logout', wrapHandler((req, res) => auth.logout(req, res)));
|
|
46
|
+
app.post('/forgot-password', wrapHandler((req, res) => auth.forgotPassword(req, res)));
|
|
47
|
+
app.post('/reset-password', wrapHandler((req, res) => auth.resetPassword(req, res)));
|
|
48
|
+
|
|
49
|
+
// Protected - Pages
|
|
43
50
|
app.get('/dashboard', webAuthMiddleware as any, (req, res: any) => {
|
|
44
51
|
(res as InertiaResponse).inertia('dashboard');
|
|
45
52
|
});
|
|
@@ -82,13 +89,13 @@ export function registerRoutes(app: NaraApp) {
|
|
|
82
89
|
const total = Number(totalCount);
|
|
83
90
|
|
|
84
91
|
// Apply pagination and ordering
|
|
85
|
-
const
|
|
92
|
+
const usersData = await query
|
|
86
93
|
.orderBy('created_at', 'desc')
|
|
87
94
|
.limit(limit)
|
|
88
95
|
.offset(offset);
|
|
89
96
|
|
|
90
97
|
// Transform users to include is_admin and is_verified flags
|
|
91
|
-
const transformedUsers =
|
|
98
|
+
const transformedUsers = usersData.map((user: any) => ({
|
|
92
99
|
...user,
|
|
93
100
|
is_admin: user.role === 'admin',
|
|
94
101
|
is_verified: !!user.email_verified_at
|
|
@@ -112,8 +119,16 @@ export function registerRoutes(app: NaraApp) {
|
|
|
112
119
|
(res as InertiaResponse).inertia('profile');
|
|
113
120
|
});
|
|
114
121
|
|
|
122
|
+
// Protected Form Actions (same path pattern - for Inertia form handling)
|
|
123
|
+
app.post('/change-profile', webAuthMiddleware as any, wrapHandler((req, res) => profile.update(req, res)));
|
|
124
|
+
app.post('/change-password', webAuthMiddleware as any, wrapHandler((req, res) => profile.changePassword(req, res)));
|
|
125
|
+
app.post('/users', webAuthMiddleware as any, wrapHandler((req, res) => users.store(req, res)));
|
|
126
|
+
app.put('/users/:id', webAuthMiddleware as any, wrapHandler((req, res) => users.update(req, res)));
|
|
127
|
+
app.delete('/users', webAuthMiddleware as any, wrapHandler((req, res) => users.destroy(req, res)));
|
|
128
|
+
app.post('/assets/avatar', webAuthMiddleware as any, wrapHandler((req, res) => profile.uploadAvatar(req, res)));
|
|
129
|
+
|
|
115
130
|
|
|
116
|
-
// --- API Routes ---
|
|
131
|
+
// --- API Routes (for programmatic access) ---
|
|
117
132
|
|
|
118
133
|
// Auth
|
|
119
134
|
app.post('/api/auth/login', wrapHandler((req, res) => auth.login(req, res)));
|
|
@@ -143,4 +158,12 @@ export function registerRoutes(app: NaraApp) {
|
|
|
143
158
|
const filePath = req.path.replace('/uploads/', '');
|
|
144
159
|
res.sendFile(`uploads/${filePath}`);
|
|
145
160
|
});
|
|
161
|
+
app.get('/public/*', (req, res) => {
|
|
162
|
+
const filePath = req.path.replace('/public/', '');
|
|
163
|
+
res.sendFile(`public/${filePath}`);
|
|
164
|
+
});
|
|
165
|
+
app.get('/storage/*', (req, res) => {
|
|
166
|
+
const filePath = req.path.replace('/storage/', '');
|
|
167
|
+
res.sendFile(`storage/${filePath}`);
|
|
168
|
+
});
|
|
146
169
|
}
|
package/templates/vue/server.ts
CHANGED
|
@@ -2,10 +2,11 @@ import 'dotenv/config';
|
|
|
2
2
|
import { createApp, jsonError } from '@nara-web/core';
|
|
3
3
|
import { vueAdapter } from '@nara-web/inertia-vue';
|
|
4
4
|
import { registerRoutes } from './routes/web.js';
|
|
5
|
+
import { view } from './app/services/View.js';
|
|
5
6
|
|
|
6
7
|
const app = createApp({
|
|
7
8
|
port: Number(process.env.PORT) || 3000,
|
|
8
|
-
adapter: vueAdapter()
|
|
9
|
+
adapter: vueAdapter({ viewFn: view } as any)
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
// Global error handler
|