webspresso 0.0.83 → 0.0.85
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 +84 -3
- package/bin/commands/orm-map.js +1 -1
- package/core/auth/index.js +10 -42
- package/core/auth/jwt.js +235 -0
- package/core/auth/manager.js +193 -0
- package/core/auth/middleware.js +66 -4
- package/index.d.ts +22 -0
- package/package.json +3 -3
- package/plugins/admin-panel/api.js +73 -0
- package/plugins/admin-panel/app.js +31 -13
- package/plugins/admin-panel/core/admin-module.js +75 -3
- package/plugins/admin-panel/core/registry.js +68 -0
- package/plugins/admin-panel/index.js +23 -4
- package/plugins/admin-panel/modules/custom-pages.js +40 -1
- package/plugins/admin-panel/modules/menu.js +23 -11
- package/plugins/admin-panel/modules/profile.js +192 -0
- package/plugins/content/client/inline-edit.js +33 -1
- package/plugins/cors.js +171 -0
- package/plugins/csrf/index.js +8 -1
- package/plugins/index.js +2 -0
- package/src/server.js +4 -2
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +16 -8
package/core/auth/middleware.js
CHANGED
|
@@ -28,7 +28,7 @@ function createAuthMiddleware(authManager) {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Authenticate middleware
|
|
31
|
-
* Loads user from session or remember me token
|
|
31
|
+
* Loads user from Bearer JWT header, session, or remember me token
|
|
32
32
|
* Should run on every request
|
|
33
33
|
*/
|
|
34
34
|
async function authenticate(req, res, next) {
|
|
@@ -39,8 +39,27 @@ function createAuthMiddleware(authManager) {
|
|
|
39
39
|
// Create request-bound auth helper
|
|
40
40
|
req.auth = authManager.createRequestAuth(req, res);
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
|
|
42
|
+
// Check Bearer JWT token header first
|
|
43
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
44
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
45
|
+
const token = authHeader.substring(7).trim();
|
|
46
|
+
try {
|
|
47
|
+
const payload = authManager.verifyJwt(token);
|
|
48
|
+
req.token = token;
|
|
49
|
+
req.jwtPayload = payload;
|
|
50
|
+
if (payload.id) {
|
|
51
|
+
const user = await authManager.findUserById(payload.id);
|
|
52
|
+
req.user = user || payload;
|
|
53
|
+
} else {
|
|
54
|
+
req.user = payload;
|
|
55
|
+
}
|
|
56
|
+
} catch (jwtErr) {
|
|
57
|
+
// Invalid or expired JWT token header
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Try to load user from session if not authenticated by JWT
|
|
62
|
+
if (!req.user && req.session?.userId) {
|
|
44
63
|
const user = await authManager.findUserById(req.session.userId);
|
|
45
64
|
if (user) {
|
|
46
65
|
req.user = user;
|
|
@@ -50,7 +69,7 @@ function createAuthMiddleware(authManager) {
|
|
|
50
69
|
}
|
|
51
70
|
}
|
|
52
71
|
|
|
53
|
-
// If no user in session, try remember me token
|
|
72
|
+
// If no user in session or JWT, try remember me token
|
|
54
73
|
if (!req.user && authManager.rememberTokens) {
|
|
55
74
|
const user = await authManager.verifyRememberToken(req, res);
|
|
56
75
|
if (user) {
|
|
@@ -96,6 +115,44 @@ function createAuthMiddleware(authManager) {
|
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Require JWT middleware
|
|
120
|
+
* Enforces valid Bearer JWT token in Authorization header
|
|
121
|
+
* @param {Object} [options] - Verification options
|
|
122
|
+
*/
|
|
123
|
+
function requireJwt(options = {}) {
|
|
124
|
+
return async (req, res, next) => {
|
|
125
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
126
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
127
|
+
return res.status(401).json({
|
|
128
|
+
error: 'Unauthorized',
|
|
129
|
+
message: 'Bearer token required',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const token = authHeader.substring(7).trim();
|
|
134
|
+
try {
|
|
135
|
+
const payload = authManager.verifyJwt(token, options);
|
|
136
|
+
req.token = token;
|
|
137
|
+
req.jwtPayload = payload;
|
|
138
|
+
|
|
139
|
+
if (payload.id) {
|
|
140
|
+
const user = await authManager.findUserById(payload.id);
|
|
141
|
+
req.user = user || payload;
|
|
142
|
+
} else {
|
|
143
|
+
req.user = payload;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
next();
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return res.status(401).json({
|
|
149
|
+
error: 'Unauthorized',
|
|
150
|
+
message: err.message || 'Invalid JWT token',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
99
156
|
/**
|
|
100
157
|
* Require guest middleware
|
|
101
158
|
* Redirects authenticated users away
|
|
@@ -192,6 +249,9 @@ function createAuthMiddleware(authManager) {
|
|
|
192
249
|
case 'auth':
|
|
193
250
|
return requireAuth({ api: args.includes('api') });
|
|
194
251
|
|
|
252
|
+
case 'jwt':
|
|
253
|
+
return requireJwt();
|
|
254
|
+
|
|
195
255
|
case 'guest':
|
|
196
256
|
return requireGuest();
|
|
197
257
|
|
|
@@ -216,6 +276,7 @@ function createAuthMiddleware(authManager) {
|
|
|
216
276
|
|
|
217
277
|
// Guard middleware
|
|
218
278
|
requireAuth,
|
|
279
|
+
requireJwt,
|
|
219
280
|
requireGuest,
|
|
220
281
|
requireCan,
|
|
221
282
|
requireVerified,
|
|
@@ -225,6 +286,7 @@ function createAuthMiddleware(authManager) {
|
|
|
225
286
|
|
|
226
287
|
// Aliases for route config (factories — file-router calls requireAuth(opts) / requireGuest(opts))
|
|
227
288
|
auth: requireAuth,
|
|
289
|
+
jwt: requireJwt,
|
|
228
290
|
guest: requireGuest,
|
|
229
291
|
};
|
|
230
292
|
}
|
package/index.d.ts
CHANGED
|
@@ -567,6 +567,28 @@ export function swaggerPlugin(options?: Record<string, unknown>): WebspressoPlug
|
|
|
567
567
|
|
|
568
568
|
export function healthCheckPlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
|
569
569
|
|
|
570
|
+
export interface CorsPluginOptions {
|
|
571
|
+
origin?:
|
|
572
|
+
| string
|
|
573
|
+
| string[]
|
|
574
|
+
| RegExp
|
|
575
|
+
| ((
|
|
576
|
+
origin: string,
|
|
577
|
+
callback: (err: Error | null, allow?: boolean | string) => void
|
|
578
|
+
) => void);
|
|
579
|
+
methods?: string | string[];
|
|
580
|
+
allowedHeaders?: string | string[];
|
|
581
|
+
exposedHeaders?: string | string[];
|
|
582
|
+
credentials?: boolean;
|
|
583
|
+
maxAge?: number;
|
|
584
|
+
preflightContinue?: boolean;
|
|
585
|
+
optionsSuccessStatus?: number;
|
|
586
|
+
routes?: string | string[] | boolean;
|
|
587
|
+
global?: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function corsPlugin(options?: CorsPluginOptions): WebspressoPlugin;
|
|
591
|
+
|
|
570
592
|
export interface RedirectRule {
|
|
571
593
|
from: string | RegExp;
|
|
572
594
|
to: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.85",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"nodemailer": "^9.0.3",
|
|
73
73
|
"nunjucks": "^3.2.4",
|
|
74
74
|
"sanitize-html": "^2.17.3",
|
|
75
|
-
"sharp": "^0.
|
|
75
|
+
"sharp": "^0.35.3",
|
|
76
76
|
"swup": "^4.8.3",
|
|
77
77
|
"yaml": "^2.8.3",
|
|
78
78
|
"zod": "^3.23.0",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"chokidar": "^3.5.3",
|
|
117
117
|
"dotenv": "^16.3.1",
|
|
118
118
|
"express-rate-limit": "^8.4.1",
|
|
119
|
-
"release-it": "^
|
|
119
|
+
"release-it": "^20.2.1",
|
|
120
120
|
"supertest": "^6.3.4",
|
|
121
121
|
"typescript": "~5.6.3",
|
|
122
122
|
"vitest": "^3.0.0"
|
|
@@ -161,6 +161,78 @@ function createApiHandlers(options) {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Update current user profile
|
|
166
|
+
*/
|
|
167
|
+
async function updateProfileHandler(req, res) {
|
|
168
|
+
try {
|
|
169
|
+
if (!req.session || !req.session.adminUser) {
|
|
170
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const userId = req.session.adminUser.id;
|
|
174
|
+
const { name, email, currentPassword, newPassword } = req.body || {};
|
|
175
|
+
|
|
176
|
+
if (!AdminUserRepo) {
|
|
177
|
+
return res.status(500).json({ error: 'Admin user system not initialized' });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const user = await AdminUserRepo.findById(userId);
|
|
181
|
+
if (!user) {
|
|
182
|
+
return res.status(404).json({ error: 'User not found' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const updateData = {};
|
|
186
|
+
|
|
187
|
+
if (name && typeof name === 'string' && name.trim()) {
|
|
188
|
+
updateData.name = name.trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (email && typeof email === 'string' && email.trim() && email.trim() !== user.email) {
|
|
192
|
+
const existing = await AdminUserRepo.query().where('email', email.trim()).whereNot('id', userId).first();
|
|
193
|
+
if (existing) {
|
|
194
|
+
return res.status(400).json({ error: 'Email is already in use by another user' });
|
|
195
|
+
}
|
|
196
|
+
updateData.email = email.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (newPassword) {
|
|
200
|
+
if (!currentPassword) {
|
|
201
|
+
return res.status(400).json({ error: 'Current password is required to set a new password' });
|
|
202
|
+
}
|
|
203
|
+
if (!comparePassword) {
|
|
204
|
+
return res.status(500).json({ error: 'Password comparison helper not available' });
|
|
205
|
+
}
|
|
206
|
+
const isValid = await comparePassword(currentPassword, user.password);
|
|
207
|
+
if (!isValid) {
|
|
208
|
+
return res.status(400).json({ error: 'Current password is incorrect' });
|
|
209
|
+
}
|
|
210
|
+
if (typeof newPassword !== 'string' || newPassword.length < 6) {
|
|
211
|
+
return res.status(400).json({ error: 'New password must be at least 6 characters long' });
|
|
212
|
+
}
|
|
213
|
+
updateData.password = await hashPassword(newPassword, 10);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (Object.keys(updateData).length > 0) {
|
|
217
|
+
await AdminUserRepo.update(userId, updateData);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const updatedUser = await AdminUserRepo.findById(userId);
|
|
221
|
+
const sanitized = sanitizeForOutput(updatedUser, getModelFromDb('AdminUser') || { hidden: ['password'] });
|
|
222
|
+
if (sanitized) delete sanitized.password;
|
|
223
|
+
|
|
224
|
+
req.session.adminUser = {
|
|
225
|
+
...req.session.adminUser,
|
|
226
|
+
name: updatedUser.name,
|
|
227
|
+
email: updatedUser.email,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
res.json({ success: true, user: sanitized });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
res.status(500).json({ error: error.message });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
164
236
|
/**
|
|
165
237
|
* Get all models (admin enabled)
|
|
166
238
|
*/
|
|
@@ -680,6 +752,7 @@ function createApiHandlers(options) {
|
|
|
680
752
|
loginHandler,
|
|
681
753
|
logoutHandler,
|
|
682
754
|
meHandler,
|
|
755
|
+
updateProfileHandler,
|
|
683
756
|
modelsHandler,
|
|
684
757
|
modelHandler,
|
|
685
758
|
recordsListHandler,
|
|
@@ -161,6 +161,16 @@ var routes = {
|
|
|
161
161
|
return SettingsPage;
|
|
162
162
|
}
|
|
163
163
|
},
|
|
164
|
+
'/profile': {
|
|
165
|
+
onmatch: async () => {
|
|
166
|
+
const isAuth = await checkAuth();
|
|
167
|
+
if (!isAuth) {
|
|
168
|
+
redirectToLogin();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
return ProfilePage;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
164
174
|
'/users/sessions': {
|
|
165
175
|
onmatch: async () => {
|
|
166
176
|
if (!(await guardUserManagementRoutes())) return;
|
|
@@ -274,20 +284,28 @@ function wrapCustomPageWithLayout(page, rawComp) {
|
|
|
274
284
|
var config = window.__ADMIN_CONFIG__;
|
|
275
285
|
if (config && config.pages) {
|
|
276
286
|
config.pages.forEach(function(page) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
if (window.__customPages && window.__customPages[page.id]) {
|
|
286
|
-
return wrapCustomPageWithLayout(page, window.__customPages[page.id]);
|
|
287
|
-
}
|
|
288
|
-
return createCustomPage(page);
|
|
287
|
+
var normPath = page.path ? (page.path.startsWith('/') ? page.path : '/' + page.path) : '/';
|
|
288
|
+
var routeHandler = {
|
|
289
|
+
onmatch: async () => {
|
|
290
|
+
const isAuth = await checkAuth();
|
|
291
|
+
if (!isAuth) {
|
|
292
|
+
redirectToLogin();
|
|
293
|
+
return;
|
|
289
294
|
}
|
|
290
|
-
|
|
295
|
+
const comp = (window.__customPages && window.__customPages[page.id])
|
|
296
|
+
? wrapCustomPageWithLayout(page, window.__customPages[page.id])
|
|
297
|
+
: createCustomPage(page);
|
|
298
|
+
setTimeout(function() { m.redraw(); }, 10);
|
|
299
|
+
setTimeout(function() { m.redraw(); }, 100);
|
|
300
|
+
return comp;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
if (!routes[normPath]) {
|
|
305
|
+
routes[normPath] = routeHandler;
|
|
306
|
+
}
|
|
307
|
+
if (normPath !== '/' && !routes[normPath + '/']) {
|
|
308
|
+
routes[normPath + '/'] = routeHandler;
|
|
291
309
|
}
|
|
292
310
|
});
|
|
293
311
|
}
|
|
@@ -41,6 +41,7 @@ function loadPagesFromDir(dirPath) {
|
|
|
41
41
|
|
|
42
42
|
let pageConfig = null;
|
|
43
43
|
let componentFile = null;
|
|
44
|
+
let htmlFile = null;
|
|
44
45
|
|
|
45
46
|
if (entry.isDirectory()) {
|
|
46
47
|
const folderPath = path.join(resolvedDir, entry.name);
|
|
@@ -63,13 +64,34 @@ function loadPagesFromDir(dirPath) {
|
|
|
63
64
|
break;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
|
|
68
|
+
if (!componentFile) {
|
|
69
|
+
const htmlCandidates = ['index.html', 'page.html', 'component.html', `${entry.name}.html`];
|
|
70
|
+
for (const cand of htmlCandidates) {
|
|
71
|
+
const fullCandPath = path.join(folderPath, cand);
|
|
72
|
+
if (fs.existsSync(fullCandPath)) {
|
|
73
|
+
htmlFile = fullCandPath;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
66
78
|
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
67
79
|
const jsonPath = path.join(resolvedDir, entry.name);
|
|
68
80
|
pageConfig = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
69
81
|
const baseName = entry.name.slice(0, -5);
|
|
70
82
|
const jsCandidate = path.join(resolvedDir, baseName + '.js');
|
|
83
|
+
const htmlCandidate = path.join(resolvedDir, baseName + '.html');
|
|
71
84
|
if (fs.existsSync(jsCandidate)) {
|
|
72
85
|
componentFile = jsCandidate;
|
|
86
|
+
} else if (fs.existsSync(htmlCandidate)) {
|
|
87
|
+
htmlFile = htmlCandidate;
|
|
88
|
+
}
|
|
89
|
+
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
90
|
+
const baseName = entry.name.slice(0, -5);
|
|
91
|
+
const jsonCandidate = path.join(resolvedDir, baseName + '.json');
|
|
92
|
+
if (!fs.existsSync(jsonCandidate)) {
|
|
93
|
+
pageConfig = { id: baseName, title: baseName, path: '/' + baseName };
|
|
94
|
+
htmlFile = path.join(resolvedDir, entry.name);
|
|
73
95
|
}
|
|
74
96
|
}
|
|
75
97
|
|
|
@@ -77,6 +99,9 @@ function loadPagesFromDir(dirPath) {
|
|
|
77
99
|
if (componentFile && !pageConfig.componentFile) {
|
|
78
100
|
pageConfig.componentFile = componentFile;
|
|
79
101
|
}
|
|
102
|
+
if (htmlFile && !pageConfig.htmlFile && !pageConfig.componentFile) {
|
|
103
|
+
pageConfig.htmlFile = htmlFile;
|
|
104
|
+
}
|
|
80
105
|
if (pageConfig.menu) {
|
|
81
106
|
if (pageConfig.menu === true) {
|
|
82
107
|
menuItems.push({
|
|
@@ -129,6 +154,24 @@ function registerModule(config, deps) {
|
|
|
129
154
|
registerMenuGroups(config.menuGroups, registry);
|
|
130
155
|
}
|
|
131
156
|
|
|
157
|
+
if (config.scripts) {
|
|
158
|
+
if (!Array.isArray(config.scripts)) {
|
|
159
|
+
throw new Error(`Module "${config.id}": scripts must be an array`);
|
|
160
|
+
}
|
|
161
|
+
for (const s of config.scripts) {
|
|
162
|
+
registry.registerScript(s);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (config.styles) {
|
|
167
|
+
if (!Array.isArray(config.styles)) {
|
|
168
|
+
throw new Error(`Module "${config.id}": styles must be an array`);
|
|
169
|
+
}
|
|
170
|
+
for (const s of config.styles) {
|
|
171
|
+
registry.registerStyle(s);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
132
175
|
if (config.pages !== undefined) {
|
|
133
176
|
if (!Array.isArray(config.pages)) {
|
|
134
177
|
throw new Error(`Module "${config.id}": pages must be an array`);
|
|
@@ -150,6 +193,27 @@ function registerModule(config, deps) {
|
|
|
150
193
|
menuList = menuList.concat(dirMenuItems);
|
|
151
194
|
}
|
|
152
195
|
|
|
196
|
+
for (const p of pagesList) {
|
|
197
|
+
if (p.menu && !menuList.some(m => m.id === p.id)) {
|
|
198
|
+
if (p.menu === true) {
|
|
199
|
+
menuList.push({
|
|
200
|
+
id: p.id,
|
|
201
|
+
label: p.title,
|
|
202
|
+
path: p.path,
|
|
203
|
+
icon: p.icon,
|
|
204
|
+
});
|
|
205
|
+
} else if (typeof p.menu === 'object') {
|
|
206
|
+
menuList.push({
|
|
207
|
+
id: p.id,
|
|
208
|
+
label: p.title,
|
|
209
|
+
path: p.path,
|
|
210
|
+
icon: p.icon,
|
|
211
|
+
...p.menu,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
153
217
|
if (pagesList.length > 0) {
|
|
154
218
|
registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
|
|
155
219
|
}
|
|
@@ -192,8 +256,13 @@ function registerPages(moduleId, pages, deps) {
|
|
|
192
256
|
for (const page of pages) {
|
|
193
257
|
const pageId = page.id || moduleId;
|
|
194
258
|
|
|
195
|
-
|
|
196
|
-
|
|
259
|
+
let htmlContent = page.html || null;
|
|
260
|
+
if (page.htmlFile) {
|
|
261
|
+
const resolvedHtmlPath = path.resolve(page.htmlFile);
|
|
262
|
+
if (!fs.existsSync(resolvedHtmlPath)) {
|
|
263
|
+
throw new Error(`Module "${moduleId}", page "${pageId}": htmlFile not found at ${page.htmlFile}`);
|
|
264
|
+
}
|
|
265
|
+
htmlContent = fs.readFileSync(resolvedHtmlPath, 'utf8');
|
|
197
266
|
}
|
|
198
267
|
|
|
199
268
|
registry.registerPage(pageId, {
|
|
@@ -203,6 +272,8 @@ function registerPages(moduleId, pages, deps) {
|
|
|
203
272
|
description: page.description,
|
|
204
273
|
permission: page.permission,
|
|
205
274
|
layout: page.layout !== undefined ? page.layout : true,
|
|
275
|
+
url: page.url || page.iframeUrl || undefined,
|
|
276
|
+
html: htmlContent || undefined,
|
|
206
277
|
});
|
|
207
278
|
|
|
208
279
|
if (page.componentFile) {
|
|
@@ -218,7 +289,8 @@ function registerPages(moduleId, pages, deps) {
|
|
|
218
289
|
registry.registerClientComponent(pageId, formattedCode);
|
|
219
290
|
}
|
|
220
291
|
|
|
221
|
-
|
|
292
|
+
const normPath = page.path.startsWith('/') ? page.path : '/' + page.path;
|
|
293
|
+
ctx.addRoute('get', `${adminPath}${normPath}`, optionalAuth, serveAdminPanel);
|
|
222
294
|
}
|
|
223
295
|
}
|
|
224
296
|
|
|
@@ -46,6 +46,10 @@ class AdminRegistry {
|
|
|
46
46
|
|
|
47
47
|
// Client components: JS code strings for custom page rendering
|
|
48
48
|
this.clientComponents = new Map();
|
|
49
|
+
|
|
50
|
+
// Custom scripts & styles for Admin Panel SSR rendering
|
|
51
|
+
this.scripts = [];
|
|
52
|
+
this.styles = [];
|
|
49
53
|
|
|
50
54
|
// Hooks: lifecycle hooks
|
|
51
55
|
this.hooks = {
|
|
@@ -266,6 +270,66 @@ class AdminRegistry {
|
|
|
266
270
|
return this;
|
|
267
271
|
}
|
|
268
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Register a custom script URL or definition
|
|
275
|
+
* @param {string|Object} script - Script URL string or config object { src, code, async, defer, type, position }
|
|
276
|
+
*/
|
|
277
|
+
registerScript(script) {
|
|
278
|
+
if (!script) return this;
|
|
279
|
+
if (typeof script === 'string') {
|
|
280
|
+
this.scripts.push({ src: script, position: 'head' });
|
|
281
|
+
} else if (typeof script === 'object') {
|
|
282
|
+
this.scripts.push({ position: 'head', ...script });
|
|
283
|
+
}
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Register a custom style URL or CSS definition
|
|
289
|
+
* @param {string|Object} style - Style URL string or config object { href, code, rel }
|
|
290
|
+
*/
|
|
291
|
+
registerStyle(style) {
|
|
292
|
+
if (!style) return this;
|
|
293
|
+
if (typeof style === 'string') {
|
|
294
|
+
this.styles.push({ href: style, rel: 'stylesheet' });
|
|
295
|
+
} else if (typeof style === 'object') {
|
|
296
|
+
this.styles.push({ rel: 'stylesheet', ...style });
|
|
297
|
+
}
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Get formatted HTML tags for registered styles
|
|
303
|
+
*/
|
|
304
|
+
getStylesHtml() {
|
|
305
|
+
return this.styles.map(s => {
|
|
306
|
+
if (s.code) return `<style>${s.code}</style>`;
|
|
307
|
+
if (s.href) return `<link rel="${s.rel || 'stylesheet'}" href="${s.href}">`;
|
|
308
|
+
return '';
|
|
309
|
+
}).filter(Boolean).join('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get formatted HTML tags for registered scripts
|
|
314
|
+
* @param {string} [position] - Filter by position ('head' or 'body')
|
|
315
|
+
*/
|
|
316
|
+
getScriptsHtml(position) {
|
|
317
|
+
return this.scripts
|
|
318
|
+
.filter(s => !position || s.position === position)
|
|
319
|
+
.map(s => {
|
|
320
|
+
if (s.code) return `<script>${s.code}</script>`;
|
|
321
|
+
if (s.src) {
|
|
322
|
+
const asyncAttr = s.async ? ' async' : '';
|
|
323
|
+
const deferAttr = s.defer ? ' defer' : '';
|
|
324
|
+
const typeAttr = s.type ? ` type="${s.type}"` : '';
|
|
325
|
+
return `<script src="${s.src}"${asyncAttr}${deferAttr}${typeAttr}></script>`;
|
|
326
|
+
}
|
|
327
|
+
return '';
|
|
328
|
+
})
|
|
329
|
+
.filter(Boolean)
|
|
330
|
+
.join('\n');
|
|
331
|
+
}
|
|
332
|
+
|
|
269
333
|
/**
|
|
270
334
|
* Register a client-side component for a custom page
|
|
271
335
|
* @param {string} pageId - Page ID (must match a registered page)
|
|
@@ -415,6 +479,8 @@ class AdminRegistry {
|
|
|
415
479
|
description: p.description,
|
|
416
480
|
permission: p.permission,
|
|
417
481
|
layout: p.layout !== undefined ? p.layout : true,
|
|
482
|
+
url: p.url || p.iframeUrl,
|
|
483
|
+
html: p.html,
|
|
418
484
|
hasClientComponent: this.clientComponents.has(p.id),
|
|
419
485
|
})),
|
|
420
486
|
|
|
@@ -460,6 +526,8 @@ class AdminRegistry {
|
|
|
460
526
|
this.menuGroups.clear();
|
|
461
527
|
this.fieldRenderers.clear();
|
|
462
528
|
this.clientComponents.clear();
|
|
529
|
+
this.scripts = [];
|
|
530
|
+
this.styles = [];
|
|
463
531
|
Object.keys(this.hooks).forEach(k => this.hooks[k] = []);
|
|
464
532
|
}
|
|
465
533
|
}
|
|
@@ -15,6 +15,7 @@ const { registerDashboardWidgets, generateDashboardComponent } = require('./modu
|
|
|
15
15
|
const { registerDefaultBulkActions, generateBulkActionsComponent } = require('./modules/bulk-actions');
|
|
16
16
|
const { registerDefaultPages, createCustomPageApiHandlers, generateCustomPageComponent } = require('./modules/custom-pages');
|
|
17
17
|
const { registerModelMenuItems, registerSystemMenuItems, generateMenuComponent } = require('./modules/menu');
|
|
18
|
+
const { generateProfileComponent } = require('./modules/profile');
|
|
18
19
|
const { registerModule, registerPageDir } = require('./core/admin-module');
|
|
19
20
|
|
|
20
21
|
|
|
@@ -70,10 +71,15 @@ function adminPanelPlugin(options = {}) {
|
|
|
70
71
|
version: '2.0.0',
|
|
71
72
|
description: 'Modular admin panel for Webspresso with extensions support',
|
|
72
73
|
|
|
73
|
-
// CSP requirements for admin panel scripts
|
|
74
|
+
// CSP requirements for admin panel scripts, styles, and custom page iframe sources
|
|
74
75
|
// Note: cdn.quilljs.com 301-redirects to cdn.jsdelivr.net; CSP is enforced on the final URL.
|
|
75
76
|
csp: {
|
|
76
|
-
styleSrc: [
|
|
77
|
+
styleSrc: [
|
|
78
|
+
'https://cdn.quilljs.com',
|
|
79
|
+
'https://cdn.jsdelivr.net',
|
|
80
|
+
'https://unpkg.com',
|
|
81
|
+
'https://cdn.tailwindcss.com',
|
|
82
|
+
],
|
|
77
83
|
scriptSrc: [
|
|
78
84
|
'https://cdn.quilljs.com',
|
|
79
85
|
'https://cdn.jsdelivr.net',
|
|
@@ -81,6 +87,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
81
87
|
'https://cdn.tailwindcss.com',
|
|
82
88
|
],
|
|
83
89
|
connectSrc: ['https://unpkg.com', 'https://cdn.tailwindcss.com', 'https://cdn.jsdelivr.net'],
|
|
90
|
+
frameSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
84
91
|
},
|
|
85
92
|
enabled,
|
|
86
93
|
registry, // Expose registry for external configuration
|
|
@@ -120,6 +127,12 @@ function adminPanelPlugin(options = {}) {
|
|
|
120
127
|
serveAdminPanel,
|
|
121
128
|
});
|
|
122
129
|
},
|
|
130
|
+
registerScript(script) {
|
|
131
|
+
return registry.registerScript(script);
|
|
132
|
+
},
|
|
133
|
+
registerStyle(style) {
|
|
134
|
+
return registry.registerStyle(style);
|
|
135
|
+
},
|
|
123
136
|
|
|
124
137
|
_ctx: null,
|
|
125
138
|
},
|
|
@@ -252,6 +265,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
252
265
|
ctx.addRoute('post', `${adminPath}/api/auth/login`, apiHandlers.loginHandler);
|
|
253
266
|
ctx.addRoute('post', `${adminPath}/api/auth/logout`, requireAuth, apiHandlers.logoutHandler);
|
|
254
267
|
ctx.addRoute('get', `${adminPath}/api/auth/me`, requireAuth, apiHandlers.meHandler);
|
|
268
|
+
ctx.addRoute('put', `${adminPath}/api/auth/profile`, requireAuth, apiHandlers.updateProfileHandler);
|
|
255
269
|
|
|
256
270
|
// Extensions API routes
|
|
257
271
|
ctx.addRoute('get', `${adminPath}/api/extensions/config`, requireAuth, extensionHandlers.configHandler);
|
|
@@ -273,7 +287,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
273
287
|
|
|
274
288
|
// Custom pages API routes
|
|
275
289
|
ctx.addRoute('get', `${adminPath}/api/extensions/pages/:pageId/data`, requireAuth, pageHandlers.getPageData);
|
|
276
|
-
ctx.addRoute('
|
|
290
|
+
ctx.addRoute('all', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
|
|
277
291
|
|
|
278
292
|
// User management API routes
|
|
279
293
|
if (userHandlers) {
|
|
@@ -376,6 +390,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
376
390
|
const dashboardComponent = generateDashboardComponent();
|
|
377
391
|
const bulkActionsComponent = generateBulkActionsComponent();
|
|
378
392
|
const customPageComponent = generateCustomPageComponent();
|
|
393
|
+
const profileComponent = generateProfileComponent();
|
|
379
394
|
|
|
380
395
|
const settings = registry.settings;
|
|
381
396
|
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
@@ -431,12 +446,14 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
431
446
|
.dark .ql-snow .ql-fill { fill: #94a3b8; }
|
|
432
447
|
.dark .ql-picker { color: #cbd5e1; }
|
|
433
448
|
</style>
|
|
449
|
+
${registry.getStylesHtml()}
|
|
450
|
+
${registry.getScriptsHtml('head')}
|
|
434
451
|
</head>
|
|
435
452
|
<body class="min-h-screen bg-gray-50 dark:bg-slate-950 text-gray-900 dark:text-slate-100 antialiased">
|
|
436
453
|
<div id="app"></div>
|
|
437
454
|
<script>
|
|
438
455
|
window.__ADMIN_PATH__ = ${JSON.stringify(adminPath)};
|
|
439
|
-
window.__ADMIN_CONFIG__ = ${JSON.stringify(registry.toClientConfig())};
|
|
456
|
+
window.__ADMIN_CONFIG__ = ${JSON.stringify(registry.toClientConfig()).replace(/</g, '\\u003c')};
|
|
440
457
|
</script>
|
|
441
458
|
<script>
|
|
442
459
|
// Helper functions
|
|
@@ -455,6 +472,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
455
472
|
${dashboardComponent}
|
|
456
473
|
${bulkActionsComponent}
|
|
457
474
|
${customPageComponent}
|
|
475
|
+
${profileComponent}
|
|
458
476
|
|
|
459
477
|
// Custom page components container
|
|
460
478
|
window.__customPages = window.__customPages || {};
|
|
@@ -465,6 +483,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
465
483
|
${fieldRenderersCode}
|
|
466
484
|
</script>
|
|
467
485
|
<script>${appScript}</script>
|
|
486
|
+
${registry.getScriptsHtml('body')}
|
|
468
487
|
</body>
|
|
469
488
|
</html>`;
|
|
470
489
|
}
|