webspresso 0.0.36 → 0.0.38

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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Admin Password Command
3
+ * Reset admin user password via CLI
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const readline = require('readline');
9
+
10
+ function registerCommand(program) {
11
+ program
12
+ .command('admin:password')
13
+ .description('Reset admin user password')
14
+ .option('-e, --email <email>', 'Admin user email')
15
+ .option('-p, --password <password>', 'New password (not recommended, use interactive mode)')
16
+ .option('-c, --config <path>', 'Path to database config file')
17
+ .action(async (options) => {
18
+ try {
19
+ // Find project root and load database
20
+ const cwd = process.cwd();
21
+
22
+ // Try to find and load the database config
23
+ let dbConfig = null;
24
+ const configPaths = [
25
+ options.config,
26
+ path.join(cwd, 'webspresso.config.js'),
27
+ path.join(cwd, 'database.config.js'),
28
+ path.join(cwd, 'db.config.js'),
29
+ ].filter(Boolean);
30
+
31
+ for (const configPath of configPaths) {
32
+ if (fs.existsSync(configPath)) {
33
+ const config = require(configPath);
34
+ dbConfig = config.database || config;
35
+ break;
36
+ }
37
+ }
38
+
39
+ if (!dbConfig) {
40
+ console.error('❌ Error: Could not find database configuration.');
41
+ console.error(' Please run this command from your project root.');
42
+ process.exit(1);
43
+ }
44
+
45
+ // Lazy load dependencies
46
+ const bcrypt = require('bcrypt');
47
+ const knex = require('knex');
48
+
49
+ // Initialize database connection
50
+ const db = knex(dbConfig);
51
+
52
+ // Check if admin_users table exists
53
+ const hasTable = await db.schema.hasTable('admin_users');
54
+ if (!hasTable) {
55
+ console.error('❌ Error: admin_users table does not exist.');
56
+ console.error(' Run "webspresso admin:setup" and "webspresso db:migrate" first.');
57
+ await db.destroy();
58
+ process.exit(1);
59
+ }
60
+
61
+ // Get email (interactive if not provided)
62
+ let email = options.email;
63
+ if (!email) {
64
+ const rl = readline.createInterface({
65
+ input: process.stdin,
66
+ output: process.stdout,
67
+ });
68
+
69
+ email = await new Promise((resolve) => {
70
+ rl.question('Enter admin email: ', (answer) => {
71
+ rl.close();
72
+ resolve(answer.trim());
73
+ });
74
+ });
75
+ }
76
+
77
+ if (!email) {
78
+ console.error('❌ Error: Email is required.');
79
+ await db.destroy();
80
+ process.exit(1);
81
+ }
82
+
83
+ // Check if user exists
84
+ const user = await db('admin_users').where({ email }).first();
85
+ if (!user) {
86
+ console.error(`❌ Error: Admin user with email "${email}" not found.`);
87
+
88
+ // Show available users
89
+ const users = await db('admin_users').select('id', 'email', 'name');
90
+ if (users.length > 0) {
91
+ console.log('\nAvailable admin users:');
92
+ users.forEach(u => console.log(` - ${u.email} (${u.name || 'No name'})`));
93
+ }
94
+
95
+ await db.destroy();
96
+ process.exit(1);
97
+ }
98
+
99
+ // Get new password (interactive if not provided)
100
+ let password = options.password;
101
+ if (!password) {
102
+ const rl = readline.createInterface({
103
+ input: process.stdin,
104
+ output: process.stdout,
105
+ });
106
+
107
+ // Disable echo for password input
108
+ if (process.stdin.isTTY) {
109
+ process.stdout.write('Enter new password: ');
110
+ password = await new Promise((resolve) => {
111
+ let pwd = '';
112
+ process.stdin.setRawMode(true);
113
+ process.stdin.resume();
114
+ process.stdin.on('data', (char) => {
115
+ char = char.toString();
116
+ if (char === '\n' || char === '\r') {
117
+ process.stdin.setRawMode(false);
118
+ process.stdin.pause();
119
+ console.log(); // New line after password
120
+ resolve(pwd);
121
+ } else if (char === '\u0003') {
122
+ // Ctrl+C
123
+ process.exit();
124
+ } else if (char === '\u007F') {
125
+ // Backspace
126
+ if (pwd.length > 0) {
127
+ pwd = pwd.slice(0, -1);
128
+ process.stdout.write('\b \b');
129
+ }
130
+ } else {
131
+ pwd += char;
132
+ process.stdout.write('*');
133
+ }
134
+ });
135
+ });
136
+ rl.close();
137
+ } else {
138
+ password = await new Promise((resolve) => {
139
+ rl.question('Enter new password: ', (answer) => {
140
+ rl.close();
141
+ resolve(answer);
142
+ });
143
+ });
144
+ }
145
+ }
146
+
147
+ if (!password || password.length < 6) {
148
+ console.error('❌ Error: Password must be at least 6 characters.');
149
+ await db.destroy();
150
+ process.exit(1);
151
+ }
152
+
153
+ // Hash the password
154
+ const hashedPassword = await bcrypt.hash(password, 10);
155
+
156
+ // Update the password
157
+ await db('admin_users')
158
+ .where({ email })
159
+ .update({
160
+ password: hashedPassword,
161
+ updated_at: new Date(),
162
+ });
163
+
164
+ console.log(`\n✅ Password updated successfully for: ${email}\n`);
165
+
166
+ await db.destroy();
167
+ } catch (err) {
168
+ console.error('❌ Error:', err.message);
169
+ process.exit(1);
170
+ }
171
+ });
172
+
173
+ // Also add a command to list admin users
174
+ program
175
+ .command('admin:list')
176
+ .description('List all admin users')
177
+ .option('-c, --config <path>', 'Path to database config file')
178
+ .action(async (options) => {
179
+ try {
180
+ const cwd = process.cwd();
181
+
182
+ // Try to find and load the database config
183
+ let dbConfig = null;
184
+ const configPaths = [
185
+ options.config,
186
+ path.join(cwd, 'webspresso.config.js'),
187
+ path.join(cwd, 'database.config.js'),
188
+ path.join(cwd, 'db.config.js'),
189
+ ].filter(Boolean);
190
+
191
+ for (const configPath of configPaths) {
192
+ if (fs.existsSync(configPath)) {
193
+ const config = require(configPath);
194
+ dbConfig = config.database || config;
195
+ break;
196
+ }
197
+ }
198
+
199
+ if (!dbConfig) {
200
+ console.error('❌ Error: Could not find database configuration.');
201
+ process.exit(1);
202
+ }
203
+
204
+ const knex = require('knex');
205
+ const db = knex(dbConfig);
206
+
207
+ // Check if admin_users table exists
208
+ const hasTable = await db.schema.hasTable('admin_users');
209
+ if (!hasTable) {
210
+ console.log('ℹ️ admin_users table does not exist yet.');
211
+ console.log(' Run "webspresso admin:setup" and "webspresso db:migrate" first.');
212
+ await db.destroy();
213
+ return;
214
+ }
215
+
216
+ // Get all admin users
217
+ const users = await db('admin_users')
218
+ .select('id', 'email', 'name', 'role', 'active', 'created_at')
219
+ .orderBy('id');
220
+
221
+ if (users.length === 0) {
222
+ console.log('\nNo admin users found.\n');
223
+ console.log('Create the first admin user via the admin panel setup page.');
224
+ } else {
225
+ console.log(`\n📋 Admin Users (${users.length}):\n`);
226
+ console.log(' ID | Email | Name | Role | Active | Created');
227
+ console.log(' ' + '-'.repeat(90));
228
+
229
+ users.forEach(user => {
230
+ const email = (user.email || '').padEnd(30).slice(0, 30);
231
+ const name = (user.name || '-').padEnd(14).slice(0, 14);
232
+ const role = (user.role || 'admin').padEnd(7).slice(0, 7);
233
+ const active = user.active ? ' ✓ ' : ' ✗ ';
234
+ const created = user.created_at
235
+ ? new Date(user.created_at).toLocaleDateString()
236
+ : '-';
237
+
238
+ console.log(` ${String(user.id).padStart(3)} | ${email} | ${name} | ${role} | ${active} | ${created}`);
239
+ });
240
+ console.log();
241
+ }
242
+
243
+ await db.destroy();
244
+ } catch (err) {
245
+ console.error('❌ Error:', err.message);
246
+ process.exit(1);
247
+ }
248
+ });
249
+ }
250
+
251
+ module.exports = { registerCommand };
package/bin/webspresso.js CHANGED
@@ -25,6 +25,7 @@ const { registerCommand: registerDbStatus } = require('./commands/db-status');
25
25
  const { registerCommand: registerDbMake } = require('./commands/db-make');
26
26
  const { registerCommand: registerSeed } = require('./commands/seed');
27
27
  const { registerCommand: registerAdminSetup } = require('./commands/admin-setup');
28
+ const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
28
29
 
29
30
  registerNew(program);
30
31
  registerPage(program);
@@ -38,6 +39,7 @@ registerDbStatus(program);
38
39
  registerDbMake(program);
39
40
  registerSeed(program);
40
41
  registerAdminSetup(program);
42
+ registerAdminPassword(program);
41
43
 
42
44
  // Parse arguments
43
45
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
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
  "bin": {
@@ -75,6 +75,16 @@ m.route(document.getElementById('app'), '/', {
75
75
  return SetupForm;
76
76
  }
77
77
  },
78
+ '/settings': {
79
+ onmatch: async () => {
80
+ const isAuth = await checkAuth();
81
+ if (!isAuth) {
82
+ m.route.set('/login');
83
+ return;
84
+ }
85
+ return SettingsPage;
86
+ }
87
+ },
78
88
  '/models/:model': {
79
89
  onmatch: async () => {
80
90
  const isAuth = await checkAuth();
@@ -68,6 +68,9 @@ const state = {
68
68
  filters: {}, // Active filters { column: { op, value, from, to } }
69
69
  filterPanelOpen: false, // Filter panel visibility (deprecated)
70
70
  filterDrawerOpen: false, // Filter drawer visibility
71
+ bulkFields: [], // Bulk-updatable fields (enum/boolean)
72
+ bulkFieldDropdownOpen: false, // Bulk field dropdown visibility
73
+ selectedBulkField: null, // Currently selected bulk field for update
71
74
  };
72
75
 
73
76
  // Breadcrumb Component
@@ -1303,6 +1306,152 @@ function formatCellValue(value, col) {
1303
1306
  }
1304
1307
  }
1305
1308
 
1309
+ // Load bulk-updatable fields for a model
1310
+ async function loadBulkFields(modelName) {
1311
+ try {
1312
+ const response = await api.get('/extensions/bulk-fields/' + modelName);
1313
+ state.bulkFields = response.fields || [];
1314
+ m.redraw();
1315
+ } catch (err) {
1316
+ console.error('Failed to load bulk fields:', err);
1317
+ state.bulkFields = [];
1318
+ }
1319
+ }
1320
+
1321
+ // Execute bulk field update
1322
+ async function executeBulkFieldUpdate(modelName, field, value, ids) {
1323
+ try {
1324
+ const response = await api.post('/extensions/bulk-update/' + modelName, {
1325
+ ids: ids,
1326
+ field: field,
1327
+ value: value,
1328
+ });
1329
+ return response;
1330
+ } catch (err) {
1331
+ throw err;
1332
+ }
1333
+ }
1334
+
1335
+ // Bulk Field Update Dropdown Component
1336
+ const BulkFieldUpdateDropdown = {
1337
+ view: (vnode) => {
1338
+ const { modelName, selectedIds, onComplete } = vnode.attrs;
1339
+
1340
+ if (!state.bulkFields || state.bulkFields.length === 0) {
1341
+ return null;
1342
+ }
1343
+
1344
+ return m('.relative.inline-block', [
1345
+ // Dropdown trigger
1346
+ m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-purple-600.bg-white.border.border-purple-200.rounded.hover:bg-purple-50.transition-colors', {
1347
+ disabled: state.bulkActionInProgress,
1348
+ onclick: (e) => {
1349
+ e.stopPropagation();
1350
+ state.bulkFieldDropdownOpen = !state.bulkFieldDropdownOpen;
1351
+ state.selectedBulkField = null;
1352
+ m.redraw();
1353
+ },
1354
+ }, [
1355
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1356
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z' })
1357
+ ),
1358
+ 'Set Field',
1359
+ m('svg.w-4.h-4.ml-1', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1360
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 9l-7 7-7-7' })
1361
+ ),
1362
+ ]),
1363
+
1364
+ // Dropdown menu
1365
+ state.bulkFieldDropdownOpen && m('.absolute.z-50.mt-1.w-64.bg-white.rounded-lg.shadow-lg.border.border-gray-200.overflow-hidden', {
1366
+ style: 'left: 0; top: 100%;',
1367
+ onclick: (e) => e.stopPropagation(),
1368
+ }, [
1369
+ // Close button area click handler
1370
+ m('.fixed.inset-0.z-40', {
1371
+ onclick: () => {
1372
+ state.bulkFieldDropdownOpen = false;
1373
+ state.selectedBulkField = null;
1374
+ m.redraw();
1375
+ },
1376
+ }),
1377
+
1378
+ // Dropdown content
1379
+ m('.relative.z-50.bg-white', [
1380
+ // Header
1381
+ m('.px-3.py-2.bg-gray-50.border-b.border-gray-200', [
1382
+ m('span.text-xs.font-medium.text-gray-500.uppercase.tracking-wider',
1383
+ state.selectedBulkField ? 'Select Value' : 'Select Field'
1384
+ ),
1385
+ ]),
1386
+
1387
+ // Field list or value list
1388
+ m('.max-h-64.overflow-y-auto', [
1389
+ state.selectedBulkField
1390
+ // Show values for selected field
1391
+ ? state.selectedBulkField.options.map(option =>
1392
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1393
+ onclick: async () => {
1394
+ state.bulkActionInProgress = true;
1395
+ state.bulkFieldDropdownOpen = false;
1396
+ m.redraw();
1397
+
1398
+ try {
1399
+ await executeBulkFieldUpdate(modelName, state.selectedBulkField.name, option.value, selectedIds);
1400
+ state.selectedBulkField = null;
1401
+ if (onComplete) onComplete();
1402
+ } catch (err) {
1403
+ alert('Error: ' + err.message);
1404
+ } finally {
1405
+ state.bulkActionInProgress = false;
1406
+ m.redraw();
1407
+ }
1408
+ },
1409
+ }, [
1410
+ m('span.text-gray-700', String(option.label)),
1411
+ state.selectedBulkField.type === 'boolean' && m('span.ml-2',
1412
+ option.value === true
1413
+ ? m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800', '✓')
1414
+ : m('span.inline-flex.items-center.px-2.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-600', '✗')
1415
+ ),
1416
+ ])
1417
+ )
1418
+ // Show field list
1419
+ : state.bulkFields.map(field =>
1420
+ m('button.w-full.px-3.py-2.text-left.text-sm.hover:bg-purple-50.flex.items-center.justify-between.transition-colors', {
1421
+ onclick: () => {
1422
+ state.selectedBulkField = field;
1423
+ m.redraw();
1424
+ },
1425
+ }, [
1426
+ m('.flex.items-center.gap-2', [
1427
+ m('span.text-gray-700', formatColumnLabel(field.label || field.name)),
1428
+ m('span.text-xs.text-gray-400.uppercase', field.type),
1429
+ ]),
1430
+ m('svg.w-4.h-4.text-gray-400', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1431
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M9 5l7 7-7 7' })
1432
+ ),
1433
+ ])
1434
+ ),
1435
+
1436
+ // Back button when viewing values
1437
+ state.selectedBulkField && m('button.w-full.px-3.py-2.text-left.text-sm.text-gray-500.hover:bg-gray-50.border-t.border-gray-100.flex.items-center.gap-1', {
1438
+ onclick: () => {
1439
+ state.selectedBulkField = null;
1440
+ m.redraw();
1441
+ },
1442
+ }, [
1443
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1444
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M15 19l-7-7 7-7' })
1445
+ ),
1446
+ 'Back to fields',
1447
+ ]),
1448
+ ]),
1449
+ ]),
1450
+ ]),
1451
+ ]);
1452
+ },
1453
+ };
1454
+
1306
1455
  // Get columns to display in table (limit to reasonable number)
1307
1456
  function getDisplayColumns(columns) {
1308
1457
  if (!columns || columns.length === 0) return [];
@@ -1454,34 +1603,55 @@ function loadRecords(modelName, page = 1, filters = null) {
1454
1603
  });
1455
1604
  }
1456
1605
 
1606
+ // Initialize model data
1607
+ function initializeModelView(modelName) {
1608
+ state.records = [];
1609
+ state.currentModelMeta = null;
1610
+ state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
1611
+ state.filterPanelOpen = false;
1612
+ state.filterDrawerOpen = false;
1613
+ state.selectedRecords = new Set(); // Bulk selection
1614
+ state.bulkActionInProgress = false;
1615
+ state.bulkFields = []; // Reset bulk fields
1616
+ state.bulkFieldDropdownOpen = false;
1617
+ state.selectedBulkField = null;
1618
+ state._currentModelName = modelName;
1619
+
1620
+ // Parse filters from URL query string
1621
+ const urlParams = new URLSearchParams(window.location.search);
1622
+ const page = parseInt(urlParams.get('page')) || 1;
1623
+ state.filters = parseFilterQuery(window.location.search);
1624
+
1625
+ // Load model metadata first, then records
1626
+ state.loading = true;
1627
+ api.get('/models/' + modelName)
1628
+ .then(modelMeta => {
1629
+ state.currentModelMeta = modelMeta;
1630
+ state.currentModel = modelMeta;
1631
+ // Load bulk-updatable fields for this model
1632
+ loadBulkFields(modelName);
1633
+ return loadRecords(modelName, page, state.filters);
1634
+ })
1635
+ .catch(err => {
1636
+ state.error = err.message;
1637
+ state.loading = false;
1638
+ m.redraw();
1639
+ });
1640
+ }
1641
+
1457
1642
  // Record List Component - displays records with dynamic columns
1458
1643
  const RecordList = {
1459
1644
  oninit: () => {
1460
1645
  const modelName = m.route.param('model');
1461
- state.records = [];
1462
- state.currentModelMeta = null;
1463
- state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
1464
- state.filterPanelOpen = false;
1465
- state.filterDrawerOpen = false;
1466
-
1467
- // Parse filters from URL query string
1468
- const urlParams = new URLSearchParams(window.location.search);
1469
- const page = parseInt(urlParams.get('page')) || 1;
1470
- state.filters = parseFilterQuery(window.location.search);
1471
-
1472
- // Load model metadata first, then records
1473
- state.loading = true;
1474
- api.get('/models/' + modelName)
1475
- .then(modelMeta => {
1476
- state.currentModelMeta = modelMeta;
1477
- state.currentModel = modelMeta;
1478
- return loadRecords(modelName, page, state.filters);
1479
- })
1480
- .catch(err => {
1481
- state.error = err.message;
1482
- state.loading = false;
1483
- m.redraw();
1484
- });
1646
+ initializeModelView(modelName);
1647
+ },
1648
+ onbeforeupdate: () => {
1649
+ // Check if model changed (navigation between different models)
1650
+ const modelName = m.route.param('model');
1651
+ if (state._currentModelName !== modelName) {
1652
+ initializeModelView(modelName);
1653
+ }
1654
+ return true;
1485
1655
  },
1486
1656
  view: () => {
1487
1657
  const modelName = m.route.param('model');
@@ -1601,12 +1771,134 @@ const RecordList = {
1601
1771
  m('p.text-gray-500', activeFilterCount > 0 ? 'Try adjusting your filters' : 'Get started by creating your first record'),
1602
1772
  ])
1603
1773
  : m('.bg-white.rounded-lg.shadow-sm.border.border-gray-200.overflow-hidden', [
1774
+ // Bulk Actions Toolbar (shown when items selected)
1775
+ state.selectedRecords && state.selectedRecords.size > 0 && m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
1776
+ m('span.text-sm.text-indigo-700.font-medium',
1777
+ state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
1778
+ ),
1779
+ m('.flex.items-center.gap-2', [
1780
+ m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-red-600.bg-white.border.border-red-200.rounded.hover:bg-red-50.transition-colors', {
1781
+ disabled: state.bulkActionInProgress,
1782
+ onclick: async () => {
1783
+ if (!confirm('Are you sure you want to delete ' + state.selectedRecords.size + ' records? This action cannot be undone.')) return;
1784
+ state.bulkActionInProgress = true;
1785
+ m.redraw();
1786
+ try {
1787
+ const ids = Array.from(state.selectedRecords);
1788
+ await api.post('/extensions/bulk-actions/bulk-delete/' + modelName, { ids });
1789
+ state.selectedRecords = new Set();
1790
+ loadRecords(modelName, state.pagination.page);
1791
+ } catch (err) {
1792
+ alert('Error: ' + err.message);
1793
+ } finally {
1794
+ state.bulkActionInProgress = false;
1795
+ m.redraw();
1796
+ }
1797
+ },
1798
+ }, [
1799
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1800
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16' })
1801
+ ),
1802
+ 'Delete',
1803
+ ]),
1804
+ m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-blue-600.bg-white.border.border-blue-200.rounded.hover:bg-blue-50.transition-colors', {
1805
+ disabled: state.bulkActionInProgress,
1806
+ onclick: async () => {
1807
+ state.bulkActionInProgress = true;
1808
+ m.redraw();
1809
+ try {
1810
+ const ids = Array.from(state.selectedRecords);
1811
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=json', { ids });
1812
+ // Download as file
1813
+ const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
1814
+ const url = URL.createObjectURL(blob);
1815
+ const a = document.createElement('a');
1816
+ a.href = url;
1817
+ a.download = modelName + '-export.json';
1818
+ a.click();
1819
+ URL.revokeObjectURL(url);
1820
+ } catch (err) {
1821
+ alert('Error: ' + err.message);
1822
+ } finally {
1823
+ state.bulkActionInProgress = false;
1824
+ m.redraw();
1825
+ }
1826
+ },
1827
+ }, [
1828
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1829
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
1830
+ ),
1831
+ 'Export JSON',
1832
+ ]),
1833
+ m('button.inline-flex.items-center.gap-1.px-3.py-1.5.text-sm.font-medium.text-green-600.bg-white.border.border-green-200.rounded.hover:bg-green-50.transition-colors', {
1834
+ disabled: state.bulkActionInProgress,
1835
+ onclick: async () => {
1836
+ state.bulkActionInProgress = true;
1837
+ m.redraw();
1838
+ try {
1839
+ const ids = Array.from(state.selectedRecords);
1840
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', { ids });
1841
+ // Download as file
1842
+ const blob = new Blob([response.data], { type: 'text/csv' });
1843
+ const url = URL.createObjectURL(blob);
1844
+ const a = document.createElement('a');
1845
+ a.href = url;
1846
+ a.download = modelName + '-export.csv';
1847
+ a.click();
1848
+ URL.revokeObjectURL(url);
1849
+ } catch (err) {
1850
+ alert('Error: ' + err.message);
1851
+ } finally {
1852
+ state.bulkActionInProgress = false;
1853
+ m.redraw();
1854
+ }
1855
+ },
1856
+ }, [
1857
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1858
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' })
1859
+ ),
1860
+ 'Export CSV',
1861
+ ]),
1862
+ // Bulk Field Update Dropdown
1863
+ m(BulkFieldUpdateDropdown, {
1864
+ modelName: modelName,
1865
+ selectedIds: Array.from(state.selectedRecords),
1866
+ onComplete: () => {
1867
+ state.selectedRecords = new Set();
1868
+ loadRecords(modelName, state.pagination.page);
1869
+ },
1870
+ }),
1871
+ m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
1872
+ onclick: () => {
1873
+ state.selectedRecords = new Set();
1874
+ state.bulkFieldDropdownOpen = false;
1875
+ state.selectedBulkField = null;
1876
+ m.redraw();
1877
+ },
1878
+ }, 'Clear'),
1879
+ ]),
1880
+ ]),
1604
1881
  // Table container with sticky header and actions
1605
1882
  m('.overflow-x-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
1606
1883
  m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
1607
1884
  // Sticky header
1608
1885
  m('thead.bg-gray-50', { style: 'position: sticky; top: 0; z-index: 10;' }, [
1609
1886
  m('tr', [
1887
+ // Checkbox column header
1888
+ m('th.px-4.py-3.text-left.bg-gray-50.border-b.border-gray-200', { style: 'width: 40px;' }, [
1889
+ m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
1890
+ checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
1891
+ indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
1892
+ onchange: (e) => {
1893
+ if (e.target.checked) {
1894
+ state.selectedRecords = new Set(state.records.map(r => r[primaryKey]));
1895
+ } else {
1896
+ state.selectedRecords = new Set();
1897
+ }
1898
+ m.redraw();
1899
+ },
1900
+ }),
1901
+ ]),
1610
1902
  // Dynamic column headers
1611
1903
  ...displayColumns.map(col =>
1612
1904
  m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.border-b.border-gray-200',
@@ -1620,7 +1912,24 @@ const RecordList = {
1620
1912
  ]),
1621
1913
  ]),
1622
1914
  m('tbody.divide-y.divide-gray-100', state.records.map(record =>
1623
- m('tr.hover:bg-gray-50.transition-colors', [
1915
+ m('tr.hover:bg-gray-50.transition-colors', {
1916
+ class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50' : '',
1917
+ }, [
1918
+ // Checkbox cell
1919
+ m('td.px-4.py-3', [
1920
+ m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
1921
+ checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
1922
+ onchange: (e) => {
1923
+ if (!state.selectedRecords) state.selectedRecords = new Set();
1924
+ if (e.target.checked) {
1925
+ state.selectedRecords.add(record[primaryKey]);
1926
+ } else {
1927
+ state.selectedRecords.delete(record[primaryKey]);
1928
+ }
1929
+ m.redraw();
1930
+ },
1931
+ }),
1932
+ ]),
1624
1933
  // Dynamic cell values
1625
1934
  ...displayColumns.map(col =>
1626
1935
  m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700',
@@ -151,6 +151,131 @@ function createExtensionApiHandlers(options) {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * Bulk update field values (for enum/boolean fields)
156
+ */
157
+ async function bulkUpdateFieldHandler(req, res) {
158
+ try {
159
+ const { model: modelName } = req.params;
160
+ const { ids, field, value } = req.body;
161
+
162
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
163
+ return res.status(400).json({ error: 'No records selected' });
164
+ }
165
+
166
+ if (!field) {
167
+ return res.status(400).json({ error: 'Field name is required' });
168
+ }
169
+
170
+ const { getModel } = require('../../../core/orm/model');
171
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
172
+
173
+ if (!model || !model.admin?.enabled) {
174
+ return res.status(404).json({ error: 'Model not found or not enabled' });
175
+ }
176
+
177
+ // Get field metadata
178
+ const column = model.columns.get(field);
179
+ if (!column) {
180
+ return res.status(400).json({ error: `Field "${field}" not found in model` });
181
+ }
182
+
183
+ // Validate field type - only allow enum and boolean
184
+ const columnMeta = column._meta || {};
185
+ const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
186
+ const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
187
+
188
+ if (!isEnum && !isBoolean) {
189
+ return res.status(400).json({ error: `Field "${field}" is not an enum or boolean type` });
190
+ }
191
+
192
+ // Validate value for enum fields
193
+ if (isEnum && !columnMeta.enum.includes(value)) {
194
+ return res.status(400).json({ error: `Invalid value "${value}" for enum field "${field}"` });
195
+ }
196
+
197
+ // Coerce boolean value
198
+ let updateValue = value;
199
+ if (isBoolean) {
200
+ updateValue = value === true || value === 'true' || value === 1 || value === '1';
201
+ }
202
+
203
+ // Perform bulk update
204
+ const repo = db.getRepository(modelName);
205
+ let updated = 0;
206
+
207
+ for (const id of ids) {
208
+ try {
209
+ await repo.update(id, { [field]: updateValue });
210
+ updated++;
211
+ } catch (e) {
212
+ console.error(`Failed to update record ${id}:`, e.message);
213
+ }
214
+ }
215
+
216
+ res.json({
217
+ success: true,
218
+ result: {
219
+ message: `${updated} of ${ids.length} records updated`,
220
+ updated,
221
+ field,
222
+ value: updateValue,
223
+ },
224
+ affected: updated,
225
+ });
226
+ } catch (error) {
227
+ console.error('Bulk update field error:', error);
228
+ res.status(500).json({ error: error.message });
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Get bulk-updatable fields for a model (enum and boolean fields)
234
+ */
235
+ function bulkFieldsHandler(req, res) {
236
+ try {
237
+ const { model: modelName } = req.params;
238
+
239
+ const { getModel } = require('../../../core/orm/model');
240
+ const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
241
+
242
+ if (!model || !model.admin?.enabled) {
243
+ return res.status(404).json({ error: 'Model not found or not enabled' });
244
+ }
245
+
246
+ const bulkFields = [];
247
+
248
+ for (const [fieldName, column] of model.columns.entries()) {
249
+ const columnMeta = column._meta || {};
250
+ const isEnum = columnMeta.enum && Array.isArray(columnMeta.enum);
251
+ const isBoolean = columnMeta.type === 'boolean' || column._def?.typeName === 'ZodBoolean';
252
+
253
+ if (isEnum) {
254
+ bulkFields.push({
255
+ name: fieldName,
256
+ type: 'enum',
257
+ label: columnMeta.label || fieldName,
258
+ options: columnMeta.enum.map(v => ({ value: v, label: v })),
259
+ });
260
+ } else if (isBoolean) {
261
+ bulkFields.push({
262
+ name: fieldName,
263
+ type: 'boolean',
264
+ label: columnMeta.label || fieldName,
265
+ options: [
266
+ { value: true, label: 'True' },
267
+ { value: false, label: 'False' },
268
+ ],
269
+ });
270
+ }
271
+ }
272
+
273
+ res.json({ fields: bulkFields });
274
+ } catch (error) {
275
+ res.status(500).json({ error: error.message });
276
+ }
277
+ }
278
+
154
279
  /**
155
280
  * Dashboard stats
156
281
  */
@@ -188,11 +313,25 @@ function createExtensionApiHandlers(options) {
188
313
 
189
314
  /**
190
315
  * Export records (CSV/JSON)
316
+ * Supports both GET (with ids in query) and POST (with ids in body)
191
317
  */
192
318
  async function exportHandler(req, res) {
193
319
  try {
194
- const { model: modelName } = req.params;
195
- const { format = 'json', ids } = req.query;
320
+ // Support both path param and query param for model name
321
+ const modelName = req.params.model || req.query.model;
322
+ const format = req.query.format || 'json';
323
+
324
+ // Support IDs from query string (GET) or body (POST)
325
+ let idList = null;
326
+ if (req.body?.ids && Array.isArray(req.body.ids)) {
327
+ idList = req.body.ids;
328
+ } else if (req.query.ids) {
329
+ idList = req.query.ids.split(',');
330
+ }
331
+
332
+ if (!modelName) {
333
+ return res.status(400).json({ error: 'Model name is required' });
334
+ }
196
335
 
197
336
  const { getModel } = require('../../../core/orm/model');
198
337
  const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
@@ -205,8 +344,7 @@ function createExtensionApiHandlers(options) {
205
344
  let records;
206
345
 
207
346
  // If specific IDs provided, fetch those
208
- if (ids) {
209
- const idList = ids.split(',');
347
+ if (idList && idList.length > 0) {
210
348
  records = [];
211
349
  for (const id of idList) {
212
350
  const record = await repo.findById(id);
@@ -224,23 +362,26 @@ function createExtensionApiHandlers(options) {
224
362
  return columns.map(col => {
225
363
  const val = record[col];
226
364
  if (val === null || val === undefined) return '';
227
- if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
365
+ if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
228
366
  return `"${val.replace(/"/g, '""')}"`;
229
367
  }
368
+ if (typeof val === 'object') {
369
+ return `"${JSON.stringify(val).replace(/"/g, '""')}"`;
370
+ }
230
371
  return String(val);
231
372
  }).join(',');
232
373
  });
233
374
 
375
+ const csvContent = [header, ...rows].join('\n');
234
376
  res.setHeader('Content-Type', 'text/csv');
235
377
  res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.csv"`);
236
- res.send([header, ...rows].join('\n'));
378
+ res.json({ data: csvContent, format: 'csv' });
237
379
  } else {
238
380
  // JSON export
239
- res.setHeader('Content-Type', 'application/json');
240
- res.setHeader('Content-Disposition', `attachment; filename="${modelName}_export.json"`);
241
381
  res.json({ data: records, model: modelName, exportedAt: new Date().toISOString() });
242
382
  }
243
383
  } catch (error) {
384
+ console.error('Export error:', error);
244
385
  res.status(500).json({ error: error.message });
245
386
  }
246
387
  }
@@ -284,6 +425,8 @@ function createExtensionApiHandlers(options) {
284
425
  widgetDataHandler,
285
426
  actionHandler,
286
427
  bulkActionHandler,
428
+ bulkUpdateFieldHandler,
429
+ bulkFieldsHandler,
287
430
  dashboardStatsHandler,
288
431
  exportHandler,
289
432
  activityLogHandler,
@@ -177,7 +177,12 @@ function adminPanelPlugin(options = {}) {
177
177
  ctx.addRoute('get', `${adminPath}/api/extensions/dashboard/stats`, requireAuth, extensionHandlers.dashboardStatsHandler);
178
178
  ctx.addRoute('post', `${adminPath}/api/extensions/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
179
179
  ctx.addRoute('post', `${adminPath}/api/extensions/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
180
+ ctx.addRoute('get', `${adminPath}/api/extensions/bulk-fields/:model`, requireAuth, extensionHandlers.bulkFieldsHandler);
181
+ ctx.addRoute('post', `${adminPath}/api/extensions/bulk-update/:model`, requireAuth, extensionHandlers.bulkUpdateFieldHandler);
180
182
  ctx.addRoute('get', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
183
+ ctx.addRoute('get', `${adminPath}/api/extensions/export`, requireAuth, extensionHandlers.exportHandler);
184
+ ctx.addRoute('post', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
185
+ ctx.addRoute('post', `${adminPath}/api/extensions/export`, requireAuth, extensionHandlers.exportHandler);
181
186
  ctx.addRoute('get', `${adminPath}/api/extensions/activity`, requireAuth, extensionHandlers.activityLogHandler);
182
187
 
183
188
  // Custom pages API routes