webspresso 0.0.89 → 0.0.91

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.
Files changed (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
@@ -0,0 +1,770 @@
1
+ /**
2
+ * Admin Panel - Admin User Management Module
3
+ * Manages administrative staff users in admin_users table
4
+ * @module plugins/admin-panel/modules/admin-users
5
+ */
6
+
7
+ const { hash } = require('../../../core/auth/hash');
8
+
9
+ /**
10
+ * DBs like SQLite store booleans as 0/1; Postgres/MySQL use real booleans.
11
+ */
12
+ function truthyBooleanForDb(db) {
13
+ try {
14
+ const client = db?.knex?.client?.config?.client;
15
+ if (client === 'sqlite3' || client === 'better-sqlite3') return 1;
16
+ } catch (_) {}
17
+ return true;
18
+ }
19
+
20
+ function normalizeBoolean(val) {
21
+ if (typeof val === 'boolean') return val;
22
+ if (typeof val === 'string') return val === 'true' || val === '1';
23
+ if (typeof val === 'number') return val === 1;
24
+ return Boolean(val);
25
+ }
26
+
27
+ /**
28
+ * Sanitize admin user object to never return password or sensitive internal props
29
+ */
30
+ function sanitizeAdminUser(user) {
31
+ if (!user) return null;
32
+ const { password, ...safeUser } = user;
33
+ return {
34
+ ...safeUser,
35
+ active: Boolean(safeUser.active),
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Register admin user management menu in admin panel
41
+ * @param {Object} options - Options
42
+ * @param {Object} options.registry - Admin registry
43
+ * @param {Object} [options.config] - Admin users config
44
+ */
45
+ function registerAdminUsersManagement(options) {
46
+ const { registry, config = {} } = options;
47
+
48
+ if (config.enabled === false) return;
49
+
50
+ // Register menu item under system group
51
+ registry.registerMenuItem({
52
+ id: 'admin-users-list',
53
+ label: config.label || 'Admin Users',
54
+ path: '/admins',
55
+ icon: config.icon || 'shield',
56
+ group: 'system',
57
+ order: config.order || 50,
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Create Admin Users API handlers
63
+ */
64
+ function createAdminUsersApiHandlers(options) {
65
+ const { db, AdminUser, hashPassword } = options;
66
+
67
+ const getRepo = () => {
68
+ if (!db || !AdminUser) return null;
69
+ return db.getRepository(AdminUser.name || 'AdminUser');
70
+ };
71
+
72
+ /**
73
+ * Helper to count total active admins in system
74
+ */
75
+ async function countActiveAdmins(repo, excludeId = null) {
76
+ let q = repo.query();
77
+ if (excludeId != null) {
78
+ q = q.whereNot('id', excludeId);
79
+ }
80
+ const isSqlite = truthyBooleanForDb(db) === 1;
81
+ if (isSqlite) {
82
+ q = q.where((builder) => builder.where('active', 1).orWhere('active', true));
83
+ } else {
84
+ q = q.where('active', true);
85
+ }
86
+ const countRes = await q.count({ count: '*' });
87
+ const count = Array.isArray(countRes) && countRes[0] ? (countRes[0].count ?? countRes[0]['count(*)']) : 0;
88
+ return Number(count) || 0;
89
+ }
90
+
91
+ /**
92
+ * List all admin users
93
+ */
94
+ async function listAdmins(req, res) {
95
+ try {
96
+ const repo = getRepo();
97
+ if (!repo) {
98
+ return res.status(500).json({ error: 'Admin user repository not available' });
99
+ }
100
+
101
+ const { search, role, active } = req.query || {};
102
+ let query = repo.query();
103
+
104
+ if (search) {
105
+ const term = `%${search.trim()}%`;
106
+ query = query.where((builder) => {
107
+ builder.where('email', 'like', term).orWhere('name', 'like', term);
108
+ });
109
+ }
110
+
111
+ if (role) {
112
+ query = query.where('role', role);
113
+ }
114
+
115
+ if (active !== undefined && active !== '') {
116
+ const isActive = active === 'true' || active === '1' || active === true;
117
+ const isSqlite = truthyBooleanForDb(db) === 1;
118
+ if (isSqlite) {
119
+ query = query.where('active', isActive ? 1 : 0);
120
+ } else {
121
+ query = query.where('active', isActive);
122
+ }
123
+ }
124
+
125
+ query = query.orderBy('created_at', 'desc');
126
+
127
+ const users = await query;
128
+ const safeUsers = (users || []).map(sanitizeAdminUser);
129
+
130
+ res.json({
131
+ success: true,
132
+ data: safeUsers,
133
+ total: safeUsers.length,
134
+ });
135
+ } catch (error) {
136
+ res.status(500).json({ error: error.message });
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get single admin user
142
+ */
143
+ async function getAdmin(req, res) {
144
+ try {
145
+ const repo = getRepo();
146
+ if (!repo) {
147
+ return res.status(500).json({ error: 'Admin user repository not available' });
148
+ }
149
+
150
+ const { id } = req.params;
151
+ const user = await repo.findById(id);
152
+ if (!user) {
153
+ return res.status(404).json({ error: 'Admin user not found' });
154
+ }
155
+
156
+ res.json({
157
+ success: true,
158
+ data: sanitizeAdminUser(user),
159
+ });
160
+ } catch (error) {
161
+ res.status(500).json({ error: error.message });
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Create new admin user
167
+ */
168
+ async function createAdmin(req, res) {
169
+ try {
170
+ const repo = getRepo();
171
+ if (!repo) {
172
+ return res.status(500).json({ error: 'Admin user repository not available' });
173
+ }
174
+
175
+ const { email, password, name, role = 'admin', active = true } = req.body || {};
176
+
177
+ if (!email || !String(email).trim()) {
178
+ return res.status(400).json({ error: 'Email is required' });
179
+ }
180
+ const cleanEmail = String(email).trim().toLowerCase();
181
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
182
+ if (!emailRegex.test(cleanEmail)) {
183
+ return res.status(400).json({ error: 'Invalid email address format' });
184
+ }
185
+
186
+ if (!password || String(password).length < 8) {
187
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
188
+ }
189
+
190
+ if (!name || !String(name).trim()) {
191
+ return res.status(400).json({ error: 'Name is required' });
192
+ }
193
+
194
+ // Check if email already exists
195
+ const existing = await repo.query().where('email', cleanEmail).first();
196
+ if (existing) {
197
+ return res.status(400).json({ error: 'An admin user with this email already exists' });
198
+ }
199
+
200
+ // Hash password
201
+ const hasher = hashPassword || ((pw) => hash(pw, 10));
202
+ const hashedPassword = await hasher(password, 10);
203
+
204
+ const isActiveBool = normalizeBoolean(active);
205
+ const isSqlite = truthyBooleanForDb(db) === 1;
206
+
207
+ const newUser = await repo.create({
208
+ email: cleanEmail,
209
+ password: hashedPassword,
210
+ name: String(name).trim(),
211
+ role: String(role).trim() || 'admin',
212
+ active: isSqlite ? (isActiveBool ? 1 : 0) : isActiveBool,
213
+ });
214
+
215
+ res.status(201).json({
216
+ success: true,
217
+ message: 'Admin user created successfully',
218
+ data: sanitizeAdminUser(newUser),
219
+ });
220
+ } catch (error) {
221
+ res.status(500).json({ error: error.message });
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Update admin user
227
+ */
228
+ async function updateAdmin(req, res) {
229
+ try {
230
+ const repo = getRepo();
231
+ if (!repo) {
232
+ return res.status(500).json({ error: 'Admin user repository not available' });
233
+ }
234
+
235
+ const { id } = req.params;
236
+ const currentAdmin = req.session?.adminUser;
237
+ const targetUser = await repo.findById(id);
238
+
239
+ if (!targetUser) {
240
+ return res.status(404).json({ error: 'Admin user not found' });
241
+ }
242
+
243
+ const { email, password, name, role, active } = req.body || {};
244
+ const updates = {};
245
+
246
+ // Name
247
+ if (name !== undefined) {
248
+ if (!String(name).trim()) {
249
+ return res.status(400).json({ error: 'Name cannot be empty' });
250
+ }
251
+ updates.name = String(name).trim();
252
+ }
253
+
254
+ // Email
255
+ if (email !== undefined) {
256
+ const cleanEmail = String(email).trim().toLowerCase();
257
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
258
+ if (!emailRegex.test(cleanEmail)) {
259
+ return res.status(400).json({ error: 'Invalid email address format' });
260
+ }
261
+ if (cleanEmail !== targetUser.email.toLowerCase()) {
262
+ const duplicate = await repo.query().where('email', cleanEmail).whereNot('id', id).first();
263
+ if (duplicate) {
264
+ return res.status(400).json({ error: 'An admin user with this email already exists' });
265
+ }
266
+ updates.email = cleanEmail;
267
+ }
268
+ }
269
+
270
+ // Role
271
+ if (role !== undefined) {
272
+ updates.role = String(role).trim() || 'admin';
273
+ }
274
+
275
+ // Active status validation & protection rules
276
+ if (active !== undefined) {
277
+ const desiredActive = normalizeBoolean(active);
278
+ const currentActive = Boolean(targetUser.active);
279
+
280
+ if (desiredActive !== currentActive) {
281
+ // Rule 1: Self-deactivation protection
282
+ if (currentAdmin && String(currentAdmin.id) === String(id) && !desiredActive) {
283
+ return res.status(400).json({ error: 'You cannot deactivate your own admin account' });
284
+ }
285
+
286
+ // Rule 2: Last active admin protection
287
+ if (!desiredActive && currentActive) {
288
+ const otherActiveCount = await countActiveAdmins(repo, id);
289
+ if (otherActiveCount < 1) {
290
+ return res.status(400).json({ error: 'Cannot deactivate the last active administrator account' });
291
+ }
292
+ }
293
+
294
+ const isSqlite = truthyBooleanForDb(db) === 1;
295
+ updates.active = isSqlite ? (desiredActive ? 1 : 0) : desiredActive;
296
+ }
297
+ }
298
+
299
+ // Password change (optional)
300
+ if (password !== undefined && String(password).trim() !== '') {
301
+ if (String(password).length < 8) {
302
+ return res.status(400).json({ error: 'New password must be at least 8 characters' });
303
+ }
304
+ const hasher = hashPassword || ((pw) => hash(pw, 10));
305
+ updates.password = await hasher(password, 10);
306
+ }
307
+
308
+ if (Object.keys(updates).length > 0) {
309
+ await repo.update(id, updates);
310
+ }
311
+
312
+ const updatedUser = await repo.findById(id);
313
+
314
+ // If current logged-in admin updated own name or email, sync session
315
+ if (currentAdmin && String(currentAdmin.id) === String(id)) {
316
+ req.session.adminUser = sanitizeAdminUser(updatedUser);
317
+ }
318
+
319
+ res.json({
320
+ success: true,
321
+ message: 'Admin user updated successfully',
322
+ data: sanitizeAdminUser(updatedUser),
323
+ });
324
+ } catch (error) {
325
+ res.status(500).json({ error: error.message });
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Delete admin user
331
+ */
332
+ async function deleteAdmin(req, res) {
333
+ try {
334
+ const repo = getRepo();
335
+ if (!repo) {
336
+ return res.status(500).json({ error: 'Admin user repository not available' });
337
+ }
338
+
339
+ const { id } = req.params;
340
+ const currentAdmin = req.session?.adminUser;
341
+
342
+ // Rule 1: Self-deletion protection
343
+ if (currentAdmin && String(currentAdmin.id) === String(id)) {
344
+ return res.status(400).json({ error: 'You cannot delete your own admin account' });
345
+ }
346
+
347
+ const targetUser = await repo.findById(id);
348
+ if (!targetUser) {
349
+ return res.status(404).json({ error: 'Admin user not found' });
350
+ }
351
+
352
+ // Rule 2: Last active admin protection
353
+ if (targetUser.active) {
354
+ const otherActiveCount = await countActiveAdmins(repo, id);
355
+ if (otherActiveCount < 1) {
356
+ return res.status(400).json({ error: 'Cannot delete the last active administrator account' });
357
+ }
358
+ }
359
+
360
+ await repo.delete(id);
361
+
362
+ res.json({
363
+ success: true,
364
+ message: 'Admin user deleted successfully',
365
+ });
366
+ } catch (error) {
367
+ res.status(500).json({ error: error.message });
368
+ }
369
+ }
370
+
371
+ return {
372
+ listAdmins,
373
+ getAdmin,
374
+ createAdmin,
375
+ updateAdmin,
376
+ deleteAdmin,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Generate Mithril AdminUsers SPA Component
382
+ */
383
+ function generateAdminUsersComponent() {
384
+ return `
385
+ const AdminUsersPage = {
386
+ oninit(vnode) {
387
+ vnode.state.loading = true;
388
+ vnode.state.admins = [];
389
+ vnode.state.search = '';
390
+ vnode.state.error = null;
391
+ vnode.state.success = null;
392
+
393
+ // Modal state
394
+ vnode.state.modalOpen = false;
395
+ vnode.state.modalMode = 'create'; // 'create' | 'edit'
396
+ vnode.state.modalLoading = false;
397
+ vnode.state.modalError = null;
398
+
399
+ // Form fields
400
+ vnode.state.formId = null;
401
+ vnode.state.formName = '';
402
+ vnode.state.formEmail = '';
403
+ vnode.state.formRole = 'admin';
404
+ vnode.state.formActive = true;
405
+ vnode.state.formPassword = '';
406
+
407
+ vnode.state.loadAdmins = async function() {
408
+ vnode.state.loading = true;
409
+ vnode.state.error = null;
410
+ try {
411
+ const q = vnode.state.search ? '?search=' + encodeURIComponent(vnode.state.search) : '';
412
+ const res = await api.get('/admins' + q);
413
+ vnode.state.admins = (res && res.data) || [];
414
+ } catch (err) {
415
+ vnode.state.error = err.message || 'Failed to load administrator accounts';
416
+ } finally {
417
+ vnode.state.loading = false;
418
+ m.redraw();
419
+ }
420
+ };
421
+
422
+ vnode.state.openCreateModal = function() {
423
+ vnode.state.modalMode = 'create';
424
+ vnode.state.formId = null;
425
+ vnode.state.formName = '';
426
+ vnode.state.formEmail = '';
427
+ vnode.state.formRole = 'admin';
428
+ vnode.state.formActive = true;
429
+ vnode.state.formPassword = '';
430
+ vnode.state.modalError = null;
431
+ vnode.state.modalOpen = true;
432
+ };
433
+
434
+ vnode.state.openEditModal = function(admin) {
435
+ vnode.state.modalMode = 'edit';
436
+ vnode.state.formId = admin.id;
437
+ vnode.state.formName = admin.name || '';
438
+ vnode.state.formEmail = admin.email || '';
439
+ vnode.state.formRole = admin.role || 'admin';
440
+ vnode.state.formActive = Boolean(admin.active);
441
+ vnode.state.formPassword = '';
442
+ vnode.state.modalError = null;
443
+ vnode.state.modalOpen = true;
444
+ };
445
+
446
+ vnode.state.saveAdmin = async function(e) {
447
+ if (e) e.preventDefault();
448
+ const s = vnode.state;
449
+ s.modalLoading = true;
450
+ s.modalError = null;
451
+ s.success = null;
452
+
453
+ try {
454
+ if (s.modalMode === 'create') {
455
+ if (!s.formPassword || s.formPassword.length < 8) {
456
+ s.modalError = 'Password must be at least 8 characters';
457
+ s.modalLoading = false;
458
+ m.redraw();
459
+ return;
460
+ }
461
+ await api.post('/admins', {
462
+ name: s.formName,
463
+ email: s.formEmail,
464
+ role: s.formRole,
465
+ active: s.formActive,
466
+ password: s.formPassword,
467
+ });
468
+ s.success = 'Administrator created successfully';
469
+ } else {
470
+ const payload = {
471
+ name: s.formName,
472
+ email: s.formEmail,
473
+ role: s.formRole,
474
+ active: s.formActive,
475
+ };
476
+ if (s.formPassword && s.formPassword.trim()) {
477
+ if (s.formPassword.length < 8) {
478
+ s.modalError = 'New password must be at least 8 characters';
479
+ s.modalLoading = false;
480
+ m.redraw();
481
+ return;
482
+ }
483
+ payload.password = s.formPassword;
484
+ }
485
+ await api.put('/admins/' + encodeURIComponent(s.formId), payload);
486
+ s.success = 'Administrator updated successfully';
487
+ }
488
+
489
+ s.modalOpen = false;
490
+ await s.loadAdmins();
491
+ } catch (err) {
492
+ s.modalError = err.message || 'Failed to save administrator';
493
+ } finally {
494
+ s.modalLoading = false;
495
+ m.redraw();
496
+ }
497
+ };
498
+
499
+ vnode.state.deleteAdmin = async function(admin) {
500
+ if (!confirm('Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.')) {
501
+ return;
502
+ }
503
+ vnode.state.loading = true;
504
+ vnode.state.error = null;
505
+ vnode.state.success = null;
506
+ try {
507
+ await api.delete('/admins/' + encodeURIComponent(admin.id));
508
+ vnode.state.success = 'Administrator deleted successfully';
509
+ await vnode.state.loadAdmins();
510
+ } catch (err) {
511
+ vnode.state.error = err.message || 'Failed to delete administrator';
512
+ vnode.state.loading = false;
513
+ m.redraw();
514
+ }
515
+ };
516
+
517
+ vnode.state.loadAdmins();
518
+ },
519
+
520
+ view(vnode) {
521
+ const s = vnode.state;
522
+ const currentAdminId = (state && state.user && state.user.id) ? String(state.user.id) : null;
523
+
524
+ return m(Layout, { breadcrumbs: [{ label: 'System', href: '/' }, { label: 'Admin Users', href: '/admins' }] }, [
525
+ m('div.space-y-6', [
526
+ // Page Header
527
+ m('div.flex.flex-col.sm:flex-row.sm:items-center.sm:justify-between.gap-4', [
528
+ m('div', [
529
+ m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100.flex.items-center.gap-2', [
530
+ m(Icon, { name: 'shield', class: 'w-7 h-7 text-blue-600 dark:text-blue-400' }),
531
+ 'Admin Users',
532
+ ]),
533
+ m('p.text-sm.text-gray-500.dark:text-slate-400', 'Manage administrator accounts, roles, and access credentials'),
534
+ ]),
535
+ m('div.flex.items-center.gap-3', [
536
+ m('button.px-4.py-2.bg-blue-600.hover:bg-blue-700.text-white.font-medium.text-sm.rounded-lg.shadow-sm.transition-colors.flex.items-center.gap-2', {
537
+ onclick: s.openCreateModal,
538
+ }, [
539
+ m(Icon, { name: 'plus', class: 'w-4 h-4' }),
540
+ 'New Administrator',
541
+ ]),
542
+ ]),
543
+ ]),
544
+
545
+ // Flash Alerts
546
+ s.error && m('div.p-4.rounded-lg.bg-red-50.dark:bg-red-900/30.border.border-red-200.dark:border-red-800.text-red-700.dark:text-red-300.text-sm.flex.items-center.justify-between', [
547
+ m('span', s.error),
548
+ m('button.text-red-500.hover:text-red-700', { onclick: () => { s.error = null; } }, m(Icon, { name: 'x', class: 'w-4 h-4' })),
549
+ ]),
550
+ s.success && m('div.p-4.rounded-lg.bg-green-50.dark:bg-green-900/30.border.border-green-200.dark:border-green-800.text-green-700.dark:text-green-300.text-sm.flex.items-center.justify-between', [
551
+ m('span', s.success),
552
+ m('button.text-green-500.hover:text-green-700', { onclick: () => { s.success = null; } }, m(Icon, { name: 'x', class: 'w-4 h-4' })),
553
+ ]),
554
+
555
+ // Search and Filter Bar
556
+ m('div.flex.items-center.gap-3.bg-white.dark:bg-slate-800.p-4.rounded-xl.border.border-gray-200.dark:border-slate-700.shadow-sm', [
557
+ m('div.relative.flex-1', [
558
+ m('div.absolute.inset-y-0.left-0.pl-3.flex.items-center.pointer-events-none', m(Icon, { name: 'search', class: 'w-4 h-4 text-gray-400' })),
559
+ m('input.w-full.pl-9.pr-4.py-2.text-sm.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', {
560
+ type: 'text',
561
+ placeholder: 'Search administrators by name or email...',
562
+ value: s.search,
563
+ oninput: (e) => { s.search = e.target.value; },
564
+ onkeyup: (e) => { if (e.key === 'Enter') s.loadAdmins(); },
565
+ }),
566
+ ]),
567
+ m('button.px-4.py-2.bg-gray-100.dark:bg-slate-700.hover:bg-gray-200.dark:hover:bg-slate-600.text-gray-700.dark:text-slate-200.text-sm.font-medium.rounded-lg.transition-colors', {
568
+ onclick: s.loadAdmins,
569
+ }, 'Filter'),
570
+ ]),
571
+
572
+ // Admin Users Table
573
+ m('div.bg-white.dark:bg-slate-800.rounded-xl.border.border-gray-200.dark:border-slate-700.shadow-sm.overflow-hidden', [
574
+ s.loading
575
+ ? m('div.flex.justify-center.items-center.py-16', m(Spinner))
576
+ : s.admins.length === 0
577
+ ? m('div.text-center.py-16', [
578
+ m(Icon, { name: 'shield', class: 'w-12 h-12 mx-auto text-gray-300 dark:text-slate-600 mb-3' }),
579
+ m('p.text-base.font-medium.text-gray-900.dark:text-slate-100', 'No administrator accounts found'),
580
+ m('p.text-sm.text-gray-500.dark:text-slate-400.mt-1', 'Create your first additional admin account or change your search filter.'),
581
+ ])
582
+ : m('div.overflow-x-auto', [
583
+ m('table.min-w-full.divide-y.divide-gray-200.dark:divide-slate-700.text-sm.text-left', [
584
+ m('thead.bg-gray-50.dark:bg-slate-900/60', [
585
+ m('tr', [
586
+ m('th.px-6.py-3.5.text-xs.font-semibold.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Administrator'),
587
+ m('th.px-6.py-3.5.text-xs.font-semibold.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Role'),
588
+ m('th.px-6.py-3.5.text-xs.font-semibold.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Status'),
589
+ m('th.px-6.py-3.5.text-xs.font-semibold.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Created At'),
590
+ m('th.px-6.py-3.5.text-right.text-xs.font-semibold.text-gray-500.dark:text-slate-400.uppercase.tracking-wider', 'Actions'),
591
+ ]),
592
+ ]),
593
+ m('tbody.divide-y.divide-gray-200.dark:divide-slate-700', s.admins.map((admin) => {
594
+ const isSelf = currentAdminId && String(admin.id) === currentAdminId;
595
+ return m('tr.hover:bg-gray-50/50.dark:hover:bg-slate-700/30.transition-colors', { key: admin.id }, [
596
+ // User info
597
+ m('td.px-6.py-4', [
598
+ m('div.flex.items-center.gap-3', [
599
+ m('div.w-9.h-9.bg-blue-100.dark:bg-blue-900/50.rounded-full.flex.items-center.justify-center.flex-shrink-0', [
600
+ m('span.text-sm.font-semibold.text-blue-600.dark:text-blue-300',
601
+ (admin.name || admin.email || 'A').charAt(0).toUpperCase()
602
+ ),
603
+ ]),
604
+ m('div', [
605
+ m('div.flex.items-center.gap-2', [
606
+ m('span.font-medium.text-gray-900.dark:text-slate-100', admin.name || 'Unnamed'),
607
+ isSelf && m('span.px-2.py-0.5.text-xs.bg-blue-100.dark:bg-blue-900/50.text-blue-700.dark:text-blue-300.rounded-full.font-medium', 'You'),
608
+ ]),
609
+ m('span.text-xs.text-gray-500.dark:text-slate-400', admin.email),
610
+ ]),
611
+ ]),
612
+ ]),
613
+
614
+ // Role
615
+ m('td.px-6.py-4', [
616
+ m('span.inline-flex.items-center.px-2.5.py-0.5.rounded-md.text-xs.font-medium.bg-gray-100.dark:bg-slate-700.text-gray-800.dark:text-slate-200',
617
+ admin.role || 'admin'
618
+ ),
619
+ ]),
620
+
621
+ // Status
622
+ m('td.px-6.py-4', [
623
+ admin.active
624
+ ? m('span.inline-flex.items-center.gap-1.5.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.dark:bg-green-900/40.text-green-800.dark:text-green-300', [
625
+ m('span.w-1.5.h-1.5.rounded-full.bg-green-500'),
626
+ 'Active',
627
+ ])
628
+ : m('span.inline-flex.items-center.gap-1.5.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-red-100.dark:bg-red-900/40.text-red-800.dark:text-red-300', [
629
+ m('span.w-1.5.h-1.5.rounded-full.bg-red-500'),
630
+ 'Inactive',
631
+ ]),
632
+ ]),
633
+
634
+ // Created Date
635
+ m('td.px-6.py-4.text-gray-500.dark:text-slate-400.text-xs',
636
+ formatDate(admin.created_at)
637
+ ),
638
+
639
+ // Actions
640
+ m('td.px-6.py-4.text-right.space-x-2', [
641
+ m('button.px-2.5.py-1.5.text-xs.font-medium.text-blue-600.dark:text-blue-400.hover:bg-blue-50.dark:hover:bg-blue-900/30.rounded.transition-colors', {
642
+ title: 'Edit Administrator',
643
+ onclick: () => s.openEditModal(admin),
644
+ }, 'Edit'),
645
+ !isSelf && m('button.px-2.5.py-1.text-xs.font-medium.text-red-600.dark:text-red-400.hover:bg-red-50.dark:hover:bg-red-900/30.rounded.transition-colors', {
646
+ title: 'Delete Administrator',
647
+ onclick: () => s.deleteAdmin(admin),
648
+ }, 'Delete'),
649
+ ]),
650
+ ]);
651
+ })),
652
+ ]),
653
+ ]),
654
+ ]),
655
+
656
+ // Modal for Add / Edit Administrator
657
+ s.modalOpen && m('div.fixed.inset-0.z-50.flex.items-center.justify-center.p-4.bg-black/50.backdrop-blur-sm', [
658
+ m('div.bg-white.dark:bg-slate-800.rounded-2xl.border.border-gray-200.dark:border-slate-700.shadow-2xl.max-w-lg.w-full.overflow-hidden.animate-fade-in', [
659
+ // Modal Header
660
+ m('div.flex.items-center.justify-between.px-6.py-4.border-b.border-gray-100.dark:border-slate-700', [
661
+ m('h2.text-lg.font-bold.text-gray-900.dark:text-slate-100',
662
+ s.modalMode === 'create' ? 'Add Administrator' : 'Edit Administrator'
663
+ ),
664
+ m('button.text-gray-400.hover:text-gray-600.dark:hover:text-slate-300', {
665
+ onclick: () => { s.modalOpen = false; },
666
+ }, m(Icon, { name: 'x', class: 'w-5 h-5' })),
667
+ ]),
668
+
669
+ // Modal Body Form
670
+ m('form', { onsubmit: s.saveAdmin }, [
671
+ m('div.p-6.space-y-4', [
672
+ s.modalError && m('div.p-3.rounded-lg.bg-red-50.dark:bg-red-900/30.text-red-700.dark:text-red-300.text-sm', s.modalError),
673
+
674
+ // Name
675
+ m('div', [
676
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Full Name'),
677
+ 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', {
678
+ type: 'text',
679
+ required: true,
680
+ placeholder: 'e.g. Ahmet Yılmaz',
681
+ value: s.formName,
682
+ oninput: (e) => { s.formName = e.target.value; },
683
+ }),
684
+ ]),
685
+
686
+ // Email
687
+ m('div', [
688
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Email Address'),
689
+ 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', {
690
+ type: 'email',
691
+ required: true,
692
+ placeholder: 'admin@example.com',
693
+ value: s.formEmail,
694
+ oninput: (e) => { s.formEmail = e.target.value; },
695
+ }),
696
+ ]),
697
+
698
+ // Role & Active Status Row
699
+ m('div.grid.grid-cols-1.sm:grid-cols-2.gap-4', [
700
+ m('div', [
701
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1', 'Role'),
702
+ m('select.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', {
703
+ value: s.formRole,
704
+ onchange: (e) => { s.formRole = e.target.value; },
705
+ }, [
706
+ m('option', { value: 'admin' }, 'admin'),
707
+ m('option', { value: 'superadmin' }, 'superadmin'),
708
+ m('option', { value: 'editor' }, 'editor'),
709
+ ]),
710
+ ]),
711
+ m('div.flex.items-center.pt-6', [
712
+ m('label.flex.items-center.gap-2.cursor-pointer', [
713
+ m('input.w-4.h-4.text-blue-600.rounded.border-gray-300.dark:border-slate-600.focus:ring-blue-500', {
714
+ type: 'checkbox',
715
+ checked: s.formActive,
716
+ disabled: s.modalMode === 'edit' && currentAdminId && String(s.formId) === currentAdminId,
717
+ onchange: (e) => { s.formActive = e.target.checked; },
718
+ }),
719
+ m('span.text-sm.font-medium.text-gray-700.dark:text-slate-300', 'Active Status'),
720
+ ]),
721
+ ]),
722
+ ]),
723
+ (s.modalMode === 'edit' && currentAdminId && String(s.formId) === currentAdminId) && m('p.text-xs.text-amber-600.dark:text-amber-400', 'You cannot deactivate your own account.'),
724
+
725
+ // Password Field
726
+ m('div', [
727
+ m('label.block.text-sm.font-medium.text-gray-700.dark:text-slate-300.mb-1',
728
+ s.modalMode === 'create' ? 'Password' : 'New Password (Optional)'
729
+ ),
730
+ 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', {
731
+ type: 'password',
732
+ required: s.modalMode === 'create',
733
+ placeholder: s.modalMode === 'create' ? 'Minimum 8 characters' : 'Leave blank to keep current password',
734
+ value: s.formPassword,
735
+ oninput: (e) => { s.formPassword = e.target.value; },
736
+ }),
737
+ m('p.text-xs.text-gray-500.dark:text-slate-400.mt-1', 'Must be at least 8 characters.'),
738
+ ]),
739
+ ]),
740
+
741
+ // Modal Footer
742
+ m('div.flex.items-center.justify-end.gap-3.px-6.py-4.bg-gray-50.dark:bg-slate-900/60.border-t.border-gray-100.dark:border-slate-700', [
743
+ m('button.px-4.py-2.text-sm.font-medium.text-gray-700.dark:text-slate-300.hover:bg-gray-100.dark:hover:bg-slate-700.rounded-lg.transition-colors', {
744
+ type: 'button',
745
+ onclick: () => { s.modalOpen = false; },
746
+ }, 'Cancel'),
747
+ m('button.px-5.py-2.text-sm.font-medium.text-white.bg-blue-600.hover:bg-blue-700.rounded-lg.shadow-sm.transition-colors.disabled:opacity-50.flex.items-center.gap-2', {
748
+ type: 'submit',
749
+ disabled: s.modalLoading,
750
+ }, [
751
+ s.modalLoading && m(Spinner),
752
+ s.modalLoading ? 'Saving...' : (s.modalMode === 'create' ? 'Create Admin' : 'Save Changes'),
753
+ ]),
754
+ ]),
755
+ ]),
756
+ ]),
757
+ ]),
758
+ ]),
759
+ ]);
760
+ },
761
+ };
762
+ `;
763
+ }
764
+
765
+ module.exports = {
766
+ registerAdminUsersManagement,
767
+ createAdminUsersApiHandlers,
768
+ generateAdminUsersComponent,
769
+ sanitizeAdminUser,
770
+ };