webspresso 0.0.82 → 0.0.84
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 +4 -3
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +73 -0
- package/plugins/admin-panel/app.js +49 -23
- package/plugins/admin-panel/core/admin-module.js +89 -4
- package/plugins/admin-panel/core/registry.js +68 -0
- package/plugins/admin-panel/index.js +14 -1
- package/plugins/admin-panel/modules/custom-pages.js +31 -0
- package/plugins/admin-panel/modules/menu.js +23 -11
- package/plugins/admin-panel/modules/profile.js +192 -0
- package/plugins/audit-log/admin-component.js +2 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -1
package/README.md
CHANGED
|
@@ -846,18 +846,19 @@ if (adminApi) {
|
|
|
846
846
|
}
|
|
847
847
|
```
|
|
848
848
|
|
|
849
|
-
**registerModule config:**
|
|
850
849
|
| Field | Description |
|
|
851
850
|
|-------|-------------|
|
|
852
851
|
| `id` | Unique module identifier (required) |
|
|
853
|
-
| `pages` | Custom admin pages (`id`, `title`, `path`, `icon`, `description`, `componentFile`, `component`, `layout`) |
|
|
852
|
+
| `pages` | Custom admin pages (`id`, `title`, `path`, `icon`, `description`, `url`, `iframeUrl`, `html`, `htmlFile`, `componentFile`, `component`, `layout`) |
|
|
854
853
|
| `pagesDir` | Path to directory containing page folders/configs for automatic discovery |
|
|
855
854
|
| `menu` | Sidebar menu items (`id`, `label`, `path`, `icon`, `order`) |
|
|
856
855
|
| `menuGroups` | Collapsible menu groups (`id`, `label`, `order`) |
|
|
857
856
|
| `api` | API routes (`prefix`, `routes`: `method`, `path`, `handler`, `auth`) |
|
|
858
857
|
| `widgets` | Dashboard widgets (`id`, `title`, `dataLoader`) |
|
|
858
|
+
| `scripts` | External script URLs, script objects, or inline scripts injected into SSR Admin Panel (`registerScript`) |
|
|
859
|
+
| `styles` | External stylesheet URLs or inline CSS injected into SSR Admin Panel (`registerStyle`) |
|
|
859
860
|
|
|
860
|
-
Pages support `componentFile`
|
|
861
|
+
Pages support JavaScript client components (`componentFile`, `pagesDir`, `component`) as well as **custom HTML & external URL pages** (`url` or `iframeUrl` for external web apps, and `html` or `htmlFile` for local HTML files like EUIX Engine apps). By default (`layout: true`), custom page views are automatically wrapped in the Admin Layout with Breadcrumbs. Set `layout: false` for full-screen / custom canvas pages.
|
|
861
862
|
|
|
862
863
|
**Example component file (`admin/reports.js`):**
|
|
863
864
|
|
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -227,28 +237,36 @@ function wrapCustomPageWithLayout(page, rawComp) {
|
|
|
227
237
|
|
|
228
238
|
return {
|
|
229
239
|
oninit: function(vnode) {
|
|
230
|
-
|
|
240
|
+
vnode.state.instance = typeof rawComp === 'function' ? rawComp.call(this, vnode) : rawComp;
|
|
241
|
+
if (vnode.state.instance && typeof vnode.state.instance.oninit === 'function') {
|
|
242
|
+
return vnode.state.instance.oninit.call(this, vnode);
|
|
243
|
+
}
|
|
231
244
|
},
|
|
232
245
|
oncreate: function(vnode) {
|
|
233
|
-
|
|
246
|
+
const comp = vnode.state.instance || rawComp;
|
|
247
|
+
if (comp && typeof comp.oncreate === 'function') return comp.oncreate.call(this, vnode);
|
|
234
248
|
},
|
|
235
249
|
onupdate: function(vnode) {
|
|
236
|
-
|
|
250
|
+
const comp = vnode.state.instance || rawComp;
|
|
251
|
+
if (comp && typeof comp.onupdate === 'function') return comp.onupdate.call(this, vnode);
|
|
237
252
|
},
|
|
238
253
|
onbeforeremove: function(vnode) {
|
|
239
|
-
|
|
254
|
+
const comp = vnode.state.instance || rawComp;
|
|
255
|
+
if (comp && typeof comp.onbeforeremove === 'function') return comp.onbeforeremove.call(this, vnode);
|
|
240
256
|
},
|
|
241
257
|
onremove: function(vnode) {
|
|
242
|
-
|
|
258
|
+
const comp = vnode.state.instance || rawComp;
|
|
259
|
+
if (comp && typeof comp.onremove === 'function') return comp.onremove.call(this, vnode);
|
|
243
260
|
},
|
|
244
261
|
view: function(vnode) {
|
|
262
|
+
const comp = vnode.state.instance || rawComp;
|
|
245
263
|
var content;
|
|
246
|
-
if (typeof
|
|
247
|
-
content =
|
|
248
|
-
} else if (typeof
|
|
249
|
-
content =
|
|
264
|
+
if (comp && typeof comp.view === 'function') {
|
|
265
|
+
content = comp.view.call(this, vnode);
|
|
266
|
+
} else if (typeof comp === 'function') {
|
|
267
|
+
content = comp.call(this, vnode);
|
|
250
268
|
} else {
|
|
251
|
-
content =
|
|
269
|
+
content = comp;
|
|
252
270
|
}
|
|
253
271
|
if (content && (content.tag === Layout || (content.tag && content.tag.name === 'Layout'))) {
|
|
254
272
|
return content;
|
|
@@ -266,20 +284,28 @@ function wrapCustomPageWithLayout(page, rawComp) {
|
|
|
266
284
|
var config = window.__ADMIN_CONFIG__;
|
|
267
285
|
if (config && config.pages) {
|
|
268
286
|
config.pages.forEach(function(page) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
if (window.__customPages && window.__customPages[page.id]) {
|
|
278
|
-
return wrapCustomPageWithLayout(page, window.__customPages[page.id]);
|
|
279
|
-
}
|
|
280
|
-
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;
|
|
281
294
|
}
|
|
282
|
-
|
|
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;
|
|
283
309
|
}
|
|
284
310
|
});
|
|
285
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
|
|
|
@@ -265,7 +337,20 @@ function registerApiRoutes(moduleId, apiConfig, deps) {
|
|
|
265
337
|
const useAuth = route.auth !== undefined ? route.auth : defaultAuth;
|
|
266
338
|
const authMiddleware = useAuth ? requireAuth : optionalAuth;
|
|
267
339
|
|
|
268
|
-
|
|
340
|
+
const safeHandler = async (req, res, next) => {
|
|
341
|
+
try {
|
|
342
|
+
await route.handler(req, res, next);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (typeof next === 'function') {
|
|
345
|
+
next(err);
|
|
346
|
+
} else {
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
safeHandler._originalHandler = route.handler;
|
|
352
|
+
|
|
353
|
+
ctx.addRoute(method, fullPath, authMiddleware, safeHandler);
|
|
269
354
|
}
|
|
270
355
|
}
|
|
271
356
|
|
|
@@ -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
|
|
|
@@ -120,6 +121,12 @@ function adminPanelPlugin(options = {}) {
|
|
|
120
121
|
serveAdminPanel,
|
|
121
122
|
});
|
|
122
123
|
},
|
|
124
|
+
registerScript(script) {
|
|
125
|
+
return registry.registerScript(script);
|
|
126
|
+
},
|
|
127
|
+
registerStyle(style) {
|
|
128
|
+
return registry.registerStyle(style);
|
|
129
|
+
},
|
|
123
130
|
|
|
124
131
|
_ctx: null,
|
|
125
132
|
},
|
|
@@ -252,6 +259,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
252
259
|
ctx.addRoute('post', `${adminPath}/api/auth/login`, apiHandlers.loginHandler);
|
|
253
260
|
ctx.addRoute('post', `${adminPath}/api/auth/logout`, requireAuth, apiHandlers.logoutHandler);
|
|
254
261
|
ctx.addRoute('get', `${adminPath}/api/auth/me`, requireAuth, apiHandlers.meHandler);
|
|
262
|
+
ctx.addRoute('put', `${adminPath}/api/auth/profile`, requireAuth, apiHandlers.updateProfileHandler);
|
|
255
263
|
|
|
256
264
|
// Extensions API routes
|
|
257
265
|
ctx.addRoute('get', `${adminPath}/api/extensions/config`, requireAuth, extensionHandlers.configHandler);
|
|
@@ -376,6 +384,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
376
384
|
const dashboardComponent = generateDashboardComponent();
|
|
377
385
|
const bulkActionsComponent = generateBulkActionsComponent();
|
|
378
386
|
const customPageComponent = generateCustomPageComponent();
|
|
387
|
+
const profileComponent = generateProfileComponent();
|
|
379
388
|
|
|
380
389
|
const settings = registry.settings;
|
|
381
390
|
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
@@ -431,12 +440,14 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
431
440
|
.dark .ql-snow .ql-fill { fill: #94a3b8; }
|
|
432
441
|
.dark .ql-picker { color: #cbd5e1; }
|
|
433
442
|
</style>
|
|
443
|
+
${registry.getStylesHtml()}
|
|
444
|
+
${registry.getScriptsHtml('head')}
|
|
434
445
|
</head>
|
|
435
446
|
<body class="min-h-screen bg-gray-50 dark:bg-slate-950 text-gray-900 dark:text-slate-100 antialiased">
|
|
436
447
|
<div id="app"></div>
|
|
437
448
|
<script>
|
|
438
449
|
window.__ADMIN_PATH__ = ${JSON.stringify(adminPath)};
|
|
439
|
-
window.__ADMIN_CONFIG__ = ${JSON.stringify(registry.toClientConfig())};
|
|
450
|
+
window.__ADMIN_CONFIG__ = ${JSON.stringify(registry.toClientConfig()).replace(/</g, '\\u003c')};
|
|
440
451
|
</script>
|
|
441
452
|
<script>
|
|
442
453
|
// Helper functions
|
|
@@ -455,6 +466,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
455
466
|
${dashboardComponent}
|
|
456
467
|
${bulkActionsComponent}
|
|
457
468
|
${customPageComponent}
|
|
469
|
+
${profileComponent}
|
|
458
470
|
|
|
459
471
|
// Custom page components container
|
|
460
472
|
window.__customPages = window.__customPages || {};
|
|
@@ -465,6 +477,7 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
465
477
|
${fieldRenderersCode}
|
|
466
478
|
</script>
|
|
467
479
|
<script>${appScript}</script>
|
|
480
|
+
${registry.getScriptsHtml('body')}
|
|
468
481
|
</body>
|
|
469
482
|
</html>`;
|
|
470
483
|
}
|
|
@@ -148,6 +148,37 @@ function generateCustomPageComponent() {
|
|
|
148
148
|
return `
|
|
149
149
|
// Custom Page Component Factory
|
|
150
150
|
function createCustomPage(pageConfig) {
|
|
151
|
+
if (pageConfig && (pageConfig.url || pageConfig.html)) {
|
|
152
|
+
var iframeAttrs = {
|
|
153
|
+
src: pageConfig.url || undefined,
|
|
154
|
+
srcdoc: pageConfig.html || undefined,
|
|
155
|
+
style: 'width: 100%; height: ' + (pageConfig.layout === false ? '100vh' : 'calc(100vh - 12rem)') + '; min-height: 600px; border: 0; border-radius: 0.5rem;',
|
|
156
|
+
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen',
|
|
157
|
+
allowfullscreen: true
|
|
158
|
+
};
|
|
159
|
+
if (pageConfig.layout === false) {
|
|
160
|
+
return {
|
|
161
|
+
view: function() {
|
|
162
|
+
return m('iframe', iframeAttrs);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
view: function() {
|
|
168
|
+
return m(Layout, [
|
|
169
|
+
m(Breadcrumb, { items: [{ label: pageConfig.title, href: pageConfig.path }] }),
|
|
170
|
+
m('div.mb-4.flex.items-start.justify-between.gap-4', [
|
|
171
|
+
m('div', [
|
|
172
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', pageConfig.title),
|
|
173
|
+
pageConfig.description && m('p.text-gray-500.dark:text-slate-400.mt-1', pageConfig.description)
|
|
174
|
+
])
|
|
175
|
+
]),
|
|
176
|
+
m('iframe', iframeAttrs)
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
return {
|
|
152
183
|
oninit(vnode) {
|
|
153
184
|
vnode.state.data = null;
|
|
@@ -200,9 +200,11 @@ const ThemeToggle = {
|
|
|
200
200
|
const MenuItem = {
|
|
201
201
|
view(vnode) {
|
|
202
202
|
const { item, active } = vnode.attrs;
|
|
203
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
204
|
+
const href = item.path ? (item.path.startsWith('/') ? adminPath + item.path : adminPath + '/' + item.path) : '#';
|
|
203
205
|
|
|
204
206
|
return m('a.flex.items-center.gap-3.px-3.py-2.rounded-lg.text-sm.font-medium.transition-colors', {
|
|
205
|
-
href:
|
|
207
|
+
href: href,
|
|
206
208
|
class: active
|
|
207
209
|
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
|
|
208
210
|
: 'text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700',
|
|
@@ -210,6 +212,7 @@ const MenuItem = {
|
|
|
210
212
|
e.preventDefault();
|
|
211
213
|
sidebarOpen = false;
|
|
212
214
|
m.route.set(item.path);
|
|
215
|
+
setTimeout(() => m.redraw(), 0);
|
|
213
216
|
},
|
|
214
217
|
}, [
|
|
215
218
|
item.icon && m(Icon, { name: item.icon, class: 'w-5 h-5 flex-shrink-0' }),
|
|
@@ -336,16 +339,25 @@ const Sidebar = {
|
|
|
336
339
|
// User section
|
|
337
340
|
state.user && m('div.p-4.border-t.border-gray-200.dark:border-slate-600', [
|
|
338
341
|
m('div.flex.items-center.gap-3', [
|
|
339
|
-
m('div.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
342
|
+
m('div.flex-1.flex.items-center.gap-3.min-w-0.cursor-pointer.hover:opacity-80.transition-opacity', {
|
|
343
|
+
title: 'Edit Profile',
|
|
344
|
+
onclick: () => { sidebarOpen = false; m.route.set('/profile'); },
|
|
345
|
+
}, [
|
|
346
|
+
m('div.w-8.h-8.bg-blue-100.dark:bg-blue-900/50.rounded-full.flex.items-center.justify-center.flex-shrink-0', [
|
|
347
|
+
m('span.text-sm.font-semibold.text-blue-600.dark:text-blue-300',
|
|
348
|
+
(state.user.name || state.user.email || 'A').charAt(0).toUpperCase()
|
|
349
|
+
),
|
|
350
|
+
]),
|
|
351
|
+
m('div.flex-1.min-w-0', [
|
|
352
|
+
m('p.text-sm.font-medium.text-gray-900.dark:text-slate-100.truncate', state.user.name || 'Admin'),
|
|
353
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400.truncate', state.user.email),
|
|
354
|
+
]),
|
|
347
355
|
]),
|
|
348
|
-
m('button.p-1.text-gray-400
|
|
356
|
+
m('button.p-1.text-gray-400.dark:text-slate-500.hover:text-blue-600.dark:hover:text-blue-400.transition-colors', {
|
|
357
|
+
title: 'My Profile',
|
|
358
|
+
onclick: () => { sidebarOpen = false; m.route.set('/profile'); },
|
|
359
|
+
}, m(Icon, { name: 'user', class: 'w-4 h-4' })),
|
|
360
|
+
m('button.p-1.text-gray-400.dark:text-slate-500.hover:text-red-600.dark:hover:text-red-400.transition-colors', {
|
|
349
361
|
title: 'Logout',
|
|
350
362
|
onclick: async () => {
|
|
351
363
|
await api.post('/auth/logout');
|
|
@@ -354,7 +366,7 @@ const Sidebar = {
|
|
|
354
366
|
clearIntendedRoute();
|
|
355
367
|
m.route.set('/login');
|
|
356
368
|
},
|
|
357
|
-
}, m(Icon, { name: 'logout', class: 'w-
|
|
369
|
+
}, m(Icon, { name: 'logout', class: 'w-4 h-4' })),
|
|
358
370
|
]),
|
|
359
371
|
]),
|
|
360
372
|
]),
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile Page Component
|
|
3
|
+
* Allows logged-in admin user to view and update name, email, and password
|
|
4
|
+
* @module plugins/admin-panel/modules/profile
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function generateProfileComponent() {
|
|
8
|
+
return `
|
|
9
|
+
const ProfilePage = {
|
|
10
|
+
oninit(vnode) {
|
|
11
|
+
vnode.state.loading = true;
|
|
12
|
+
vnode.state.saving = false;
|
|
13
|
+
vnode.state.name = '';
|
|
14
|
+
vnode.state.email = '';
|
|
15
|
+
vnode.state.currentPassword = '';
|
|
16
|
+
vnode.state.newPassword = '';
|
|
17
|
+
vnode.state.confirmPassword = '';
|
|
18
|
+
vnode.state.error = null;
|
|
19
|
+
vnode.state.success = null;
|
|
20
|
+
|
|
21
|
+
api.get('/auth/me')
|
|
22
|
+
.then(res => {
|
|
23
|
+
if (res && res.user) {
|
|
24
|
+
vnode.state.name = res.user.name || '';
|
|
25
|
+
vnode.state.email = res.user.email || '';
|
|
26
|
+
}
|
|
27
|
+
vnode.state.loading = false;
|
|
28
|
+
m.redraw();
|
|
29
|
+
})
|
|
30
|
+
.catch(err => {
|
|
31
|
+
vnode.state.error = err.message || String(err);
|
|
32
|
+
vnode.state.loading = false;
|
|
33
|
+
m.redraw();
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
view(vnode) {
|
|
38
|
+
const s = vnode.state;
|
|
39
|
+
|
|
40
|
+
const handleSubmit = async (e) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
s.saving = true;
|
|
43
|
+
s.error = null;
|
|
44
|
+
s.success = null;
|
|
45
|
+
|
|
46
|
+
if (s.newPassword) {
|
|
47
|
+
if (!s.currentPassword) {
|
|
48
|
+
s.error = 'Current password is required to change your password';
|
|
49
|
+
s.saving = false;
|
|
50
|
+
m.redraw();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (s.newPassword.length < 6) {
|
|
54
|
+
s.error = 'New password must be at least 6 characters';
|
|
55
|
+
s.saving = false;
|
|
56
|
+
m.redraw();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (s.newPassword !== s.confirmPassword) {
|
|
60
|
+
s.error = 'New passwords do not match';
|
|
61
|
+
s.saving = false;
|
|
62
|
+
m.redraw();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const payload = {
|
|
69
|
+
name: s.name,
|
|
70
|
+
email: s.email,
|
|
71
|
+
};
|
|
72
|
+
if (s.newPassword) {
|
|
73
|
+
payload.currentPassword = s.currentPassword;
|
|
74
|
+
payload.newPassword = s.newPassword;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const res = await api.put('/auth/profile', payload);
|
|
78
|
+
s.saving = false;
|
|
79
|
+
if (res && res.success && res.user) {
|
|
80
|
+
s.success = 'Profile updated successfully';
|
|
81
|
+
s.currentPassword = '';
|
|
82
|
+
s.newPassword = '';
|
|
83
|
+
s.confirmPassword = '';
|
|
84
|
+
if (typeof state !== 'undefined' && state) {
|
|
85
|
+
state.user = res.user;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
m.redraw();
|
|
89
|
+
} catch (err) {
|
|
90
|
+
s.saving = false;
|
|
91
|
+
s.error = err.message || 'Failed to update profile';
|
|
92
|
+
m.redraw();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return m(Layout, { breadcrumbs: [{ label: 'Profile', href: '/profile' }] }, [
|
|
97
|
+
m('div.max-w-4xl.mx-auto.space-y-6', [
|
|
98
|
+
m('div.flex.items-center.justify-between', [
|
|
99
|
+
m('div', [
|
|
100
|
+
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'My Profile'),
|
|
101
|
+
m('p.text-sm.text-gray-500.dark:text-slate-400', 'Manage your personal details and account password'),
|
|
102
|
+
]),
|
|
103
|
+
]),
|
|
104
|
+
|
|
105
|
+
s.loading
|
|
106
|
+
? m('div.flex.justify-center.py-12', m(Spinner))
|
|
107
|
+
: m('form', { onsubmit: handleSubmit }, [
|
|
108
|
+
s.error && m('div.p-4.mb-4.rounded-lg.bg-red-50.dark:bg-red-900/30.text-red-700.dark:text-red-300.text-sm', s.error),
|
|
109
|
+
s.success && m('div.p-4.mb-4.rounded-lg.bg-green-50.dark:bg-green-900/30.text-green-700.dark:text-green-300.text-sm', s.success),
|
|
110
|
+
|
|
111
|
+
// Personal Information Card
|
|
112
|
+
m('div.bg-white.dark:bg-slate-800.rounded-xl.border.border-gray-200.dark:border-slate-700.p-6.mb-6.shadow-sm.space-y-4', [
|
|
113
|
+
m('h2.text-lg.font-semibold.text-gray-900.dark:text-slate-100.border-b.border-gray-100.dark:border-slate-700.pb-3', 'Personal Information'),
|
|
114
|
+
m('div.grid.grid-cols-1.md:grid-cols-2.gap-4', [
|
|
115
|
+
m('div', [
|
|
116
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Name'),
|
|
117
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
118
|
+
type: 'text',
|
|
119
|
+
required: true,
|
|
120
|
+
value: s.name,
|
|
121
|
+
oninput: (e) => { s.name = e.target.value; },
|
|
122
|
+
}),
|
|
123
|
+
]),
|
|
124
|
+
m('div', [
|
|
125
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Email Address'),
|
|
126
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
127
|
+
type: 'email',
|
|
128
|
+
required: true,
|
|
129
|
+
value: s.email,
|
|
130
|
+
oninput: (e) => { s.email = e.target.value; },
|
|
131
|
+
}),
|
|
132
|
+
]),
|
|
133
|
+
]),
|
|
134
|
+
]),
|
|
135
|
+
|
|
136
|
+
// Password Change Card
|
|
137
|
+
m('div.bg-white.dark:bg-slate-800.rounded-xl.border.border-gray-200.dark:border-slate-700.p-6.mb-6.shadow-sm.space-y-4', [
|
|
138
|
+
m('h2.text-lg.font-semibold.text-gray-900.dark:text-slate-100.border-b.border-gray-100.dark:border-slate-700.pb-3', 'Change Password'),
|
|
139
|
+
m('p.text-xs.text-gray-500.dark:text-slate-400', 'Leave blank if you do not wish to change your password'),
|
|
140
|
+
m('div.space-y-4', [
|
|
141
|
+
m('div', [
|
|
142
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Current Password'),
|
|
143
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
144
|
+
type: 'password',
|
|
145
|
+
value: s.currentPassword,
|
|
146
|
+
oninput: (e) => { s.currentPassword = e.target.value; },
|
|
147
|
+
placeholder: '••••••••',
|
|
148
|
+
}),
|
|
149
|
+
]),
|
|
150
|
+
m('div.grid.grid-cols-1.md:grid-cols-2.gap-4', [
|
|
151
|
+
m('div', [
|
|
152
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'New Password'),
|
|
153
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
154
|
+
type: 'password',
|
|
155
|
+
value: s.newPassword,
|
|
156
|
+
oninput: (e) => { s.newPassword = e.target.value; },
|
|
157
|
+
placeholder: 'Min 6 characters',
|
|
158
|
+
}),
|
|
159
|
+
]),
|
|
160
|
+
m('div', [
|
|
161
|
+
m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Confirm New Password'),
|
|
162
|
+
m('input.w-full.px-3.py-2.border.border-gray-300.dark:border-slate-600.rounded-lg.bg-white.dark:bg-slate-900.text-gray-900.dark:text-slate-100.focus:ring-2.focus:ring-blue-500', {
|
|
163
|
+
type: 'password',
|
|
164
|
+
value: s.confirmPassword,
|
|
165
|
+
oninput: (e) => { s.confirmPassword = e.target.value; },
|
|
166
|
+
placeholder: 'Repeat new password',
|
|
167
|
+
}),
|
|
168
|
+
]),
|
|
169
|
+
]),
|
|
170
|
+
]),
|
|
171
|
+
]),
|
|
172
|
+
|
|
173
|
+
m('div.flex.justify-end', [
|
|
174
|
+
m('button.px-6.py-2.bg-blue-600.hover:bg-blue-700.text-white.font-medium.rounded-lg.shadow-sm.transition-colors.disabled:opacity-50.flex.items-center.gap-2', {
|
|
175
|
+
type: 'submit',
|
|
176
|
+
disabled: s.saving,
|
|
177
|
+
}, [
|
|
178
|
+
s.saving && m(Spinner),
|
|
179
|
+
s.saving ? 'Saving...' : 'Save Profile',
|
|
180
|
+
]),
|
|
181
|
+
]),
|
|
182
|
+
]),
|
|
183
|
+
]),
|
|
184
|
+
]);
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
generateProfileComponent,
|
|
192
|
+
};
|
|
@@ -34,9 +34,11 @@ function generateAuditLogComponent(options = {}) {
|
|
|
34
34
|
rows = res.data || [];
|
|
35
35
|
total = (res.meta && res.meta.total) || 0;
|
|
36
36
|
loading = false;
|
|
37
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
37
38
|
}).catch(function(e) {
|
|
38
39
|
error = e.message || String(e);
|
|
39
40
|
loading = false;
|
|
41
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
40
42
|
});
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -195,7 +195,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
195
195
|
| `dashboardPlugin` | Dev route `/_webspresso` — route list |
|
|
196
196
|
| `sitemapPlugin` | `/sitemap.xml`, robots; optional DB-driven URLs |
|
|
197
197
|
| `analyticsPlugin` | GA / GTM / Yandex / Bing / Facebook — `fsy` helpers |
|
|
198
|
-
| `adminPanelPlugin` | SPA admin CRUD — needs **`db`**; optional **`uploadUrl`** (or infer from **`uploadPlugin`**); optional **`userManagement: { enabled, model, fields }`** + **`auth`** (same **`AuthManager`** as **`createApp({ auth })`**) for site-user CRUD + remember-me session UI — see **Session authentication** above |
|
|
198
|
+
| `adminPanelPlugin` | SPA admin CRUD — needs **`db`**; supports custom HTML & external URL pages (`url`, `html`, `htmlFile`, `layout: false`) and SSR script/style imports (`scripts`, `styles`, `registerScript`, `registerStyle`); optional **`uploadUrl`** (or infer from **`uploadPlugin`**); optional **`userManagement: { enabled, model, fields }`** + **`auth`** (same **`AuthManager`** as **`createApp({ auth })`**) for site-user CRUD + remember-me session UI — see **Session authentication** above |
|
|
199
199
|
| `dataExchangePlugin` | Admin-only **Excel export** + **CSV/XLSX import** under `${adminPath}/api/data-exchange/*`; register **after** `adminPanelPlugin` with same `db` / `adminPath`; optional `maxRows`, `maxFileBytes`; adds UI buttons + bulk `export-xlsx` |
|
|
200
200
|
| `redirectPlugin` | Configurable **301–308** redirects in `register()` — runs **before** file-based SSR routes; `rules` (`from` path or `RegExp`, `to`, `status`, `methods`), `preserveQuery`, `allowExternal`, `trailingSlash`, `defaultMethods`; docs **[`doc/index.html#plugins-redirect`](../../../doc/index.html#plugins-redirect)**, README **Redirect plugin** |
|
|
201
201
|
| `uploadPlugin` | `POST` multipart (`multer`), `createLocalFileProvider` or custom `provider`; set **`mimeAllowlist`** / **`maxBytes`** in production; pairs with admin **`zdb.file()`** / **`customFields.file-upload`** when registered before **`adminPanelPlugin`** |
|