webspresso 0.0.88 → 0.0.90
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 +92 -45
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +12 -2
- package/core/orm/eager-loader.js +64 -70
- package/core/orm/events.js +19 -0
- package/core/orm/model.js +8 -2
- package/core/orm/repository.js +54 -22
- package/core/orm/schema-helpers.js +31 -2
- package/core/orm/scopes.js +2 -1
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +10 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/08-record-list.js +10 -10
- package/plugins/admin-panel/index.js +41 -1
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/src/file-router.js +31 -14
- package/src/helpers.js +4 -3
- package/src/plugin-manager.js +10 -3
- package/src/server.js +184 -18
- 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
|
+
};
|