webspresso 0.0.83 → 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 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` (path to a `.js` file exporting/returning a Mithril component), `pagesDir` (directory auto-discovery), or `component` (code string). By default (`layout: true`), page views are automatically wrapped in the Admin Layout with Breadcrumbs. Set `layout: false` for full-screen / custom canvas pages.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.83",
3
+ "version": "0.0.84",
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",
@@ -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
- if (!routes[page.path]) {
278
- routes[page.path] = {
279
- onmatch: async () => {
280
- const isAuth = await checkAuth();
281
- if (!isAuth) {
282
- redirectToLogin();
283
- return;
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
- if (!page.title || !page.path) {
196
- throw new Error(`Module "${moduleId}", page "${pageId}": requires title and path`);
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
- ctx.addRoute('get', `${adminPath}${page.path}`, optionalAuth, serveAdminPanel);
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
 
@@ -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: item.path,
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.w-8.h-8.bg-gray-200 dark:bg-slate-700.rounded-full.flex.items-center.justify-center', [
340
- m('span.text-sm.font-medium.text-gray-600.dark:text-slate-300',
341
- (state.user.name || state.user.email || 'A').charAt(0).toUpperCase()
342
- ),
343
- ]),
344
- m('div.flex-1.min-w-0', [
345
- m('p.text-sm.font-medium.text-gray-900 dark:text-slate-100.truncate', state.user.name || 'Admin'),
346
- m('p.text-xs.text-gray-500 dark:text-slate-400.truncate', state.user.email),
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 dark:text-slate-500.hover:text-gray-600 dark:hover:text-slate-300', {
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-5 h-5' })),
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
+ };
@@ -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`** |