webspresso 0.0.36 → 0.0.37

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.37",
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();
@@ -1454,34 +1454,50 @@ function loadRecords(modelName, page = 1, filters = null) {
1454
1454
  });
1455
1455
  }
1456
1456
 
1457
+ // Initialize model data
1458
+ function initializeModelView(modelName) {
1459
+ state.records = [];
1460
+ state.currentModelMeta = null;
1461
+ state.pagination = { page: 1, perPage: 20, total: 0, totalPages: 0 };
1462
+ state.filterPanelOpen = false;
1463
+ state.filterDrawerOpen = false;
1464
+ state.selectedRecords = new Set(); // Bulk selection
1465
+ state.bulkActionInProgress = false;
1466
+ state._currentModelName = modelName;
1467
+
1468
+ // Parse filters from URL query string
1469
+ const urlParams = new URLSearchParams(window.location.search);
1470
+ const page = parseInt(urlParams.get('page')) || 1;
1471
+ state.filters = parseFilterQuery(window.location.search);
1472
+
1473
+ // Load model metadata first, then records
1474
+ state.loading = true;
1475
+ api.get('/models/' + modelName)
1476
+ .then(modelMeta => {
1477
+ state.currentModelMeta = modelMeta;
1478
+ state.currentModel = modelMeta;
1479
+ return loadRecords(modelName, page, state.filters);
1480
+ })
1481
+ .catch(err => {
1482
+ state.error = err.message;
1483
+ state.loading = false;
1484
+ m.redraw();
1485
+ });
1486
+ }
1487
+
1457
1488
  // Record List Component - displays records with dynamic columns
1458
1489
  const RecordList = {
1459
1490
  oninit: () => {
1460
1491
  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
- });
1492
+ initializeModelView(modelName);
1493
+ },
1494
+ onbeforeupdate: () => {
1495
+ // Check if model changed (navigation between different models)
1496
+ const modelName = m.route.param('model');
1497
+ if (state._currentModelName !== modelName) {
1498
+ initializeModelView(modelName);
1499
+ }
1500
+ return true;
1485
1501
  },
1486
1502
  view: () => {
1487
1503
  const modelName = m.route.param('model');
@@ -1601,12 +1617,123 @@ const RecordList = {
1601
1617
  m('p.text-gray-500', activeFilterCount > 0 ? 'Try adjusting your filters' : 'Get started by creating your first record'),
1602
1618
  ])
1603
1619
  : m('.bg-white.rounded-lg.shadow-sm.border.border-gray-200.overflow-hidden', [
1620
+ // Bulk Actions Toolbar (shown when items selected)
1621
+ state.selectedRecords && state.selectedRecords.size > 0 && m('.bg-indigo-50.border-b.border-indigo-100.px-4.py-3.flex.items-center.justify-between', [
1622
+ m('span.text-sm.text-indigo-700.font-medium',
1623
+ state.selectedRecords.size + ' record' + (state.selectedRecords.size > 1 ? 's' : '') + ' selected'
1624
+ ),
1625
+ m('.flex.items-center.gap-2', [
1626
+ 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', {
1627
+ disabled: state.bulkActionInProgress,
1628
+ onclick: async () => {
1629
+ if (!confirm('Are you sure you want to delete ' + state.selectedRecords.size + ' records? This action cannot be undone.')) return;
1630
+ state.bulkActionInProgress = true;
1631
+ m.redraw();
1632
+ try {
1633
+ const ids = Array.from(state.selectedRecords);
1634
+ await api.post('/extensions/bulk-actions/bulk-delete/execute?model=' + modelName, { ids });
1635
+ state.selectedRecords = new Set();
1636
+ loadRecords(modelName, state.pagination.page);
1637
+ } catch (err) {
1638
+ alert('Error: ' + err.message);
1639
+ } finally {
1640
+ state.bulkActionInProgress = false;
1641
+ m.redraw();
1642
+ }
1643
+ },
1644
+ }, [
1645
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1646
+ 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' })
1647
+ ),
1648
+ 'Delete',
1649
+ ]),
1650
+ 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', {
1651
+ disabled: state.bulkActionInProgress,
1652
+ onclick: async () => {
1653
+ state.bulkActionInProgress = true;
1654
+ m.redraw();
1655
+ try {
1656
+ const ids = Array.from(state.selectedRecords);
1657
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=json', { ids });
1658
+ // Download as file
1659
+ const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
1660
+ const url = URL.createObjectURL(blob);
1661
+ const a = document.createElement('a');
1662
+ a.href = url;
1663
+ a.download = modelName + '-export.json';
1664
+ a.click();
1665
+ URL.revokeObjectURL(url);
1666
+ } catch (err) {
1667
+ alert('Error: ' + err.message);
1668
+ } finally {
1669
+ state.bulkActionInProgress = false;
1670
+ m.redraw();
1671
+ }
1672
+ },
1673
+ }, [
1674
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1675
+ 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' })
1676
+ ),
1677
+ 'Export JSON',
1678
+ ]),
1679
+ 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', {
1680
+ disabled: state.bulkActionInProgress,
1681
+ onclick: async () => {
1682
+ state.bulkActionInProgress = true;
1683
+ m.redraw();
1684
+ try {
1685
+ const ids = Array.from(state.selectedRecords);
1686
+ const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', { ids });
1687
+ // Download as file
1688
+ const blob = new Blob([response.data], { type: 'text/csv' });
1689
+ const url = URL.createObjectURL(blob);
1690
+ const a = document.createElement('a');
1691
+ a.href = url;
1692
+ a.download = modelName + '-export.csv';
1693
+ a.click();
1694
+ URL.revokeObjectURL(url);
1695
+ } catch (err) {
1696
+ alert('Error: ' + err.message);
1697
+ } finally {
1698
+ state.bulkActionInProgress = false;
1699
+ m.redraw();
1700
+ }
1701
+ },
1702
+ }, [
1703
+ m('svg.w-4.h-4', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor' },
1704
+ 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' })
1705
+ ),
1706
+ 'Export CSV',
1707
+ ]),
1708
+ m('button.px-3.py-1.5.text-sm.text-gray-500.hover:text-gray-700', {
1709
+ onclick: () => {
1710
+ state.selectedRecords = new Set();
1711
+ m.redraw();
1712
+ },
1713
+ }, 'Clear'),
1714
+ ]),
1715
+ ]),
1604
1716
  // Table container with sticky header and actions
1605
1717
  m('.overflow-x-auto.max-h-[calc(100vh-380px)]', { style: 'position: relative;' }, [
1606
1718
  m('table.w-full.border-collapse', { style: 'min-width: 100%;' }, [
1607
1719
  // Sticky header
1608
1720
  m('thead.bg-gray-50', { style: 'position: sticky; top: 0; z-index: 10;' }, [
1609
1721
  m('tr', [
1722
+ // Checkbox column header
1723
+ m('th.px-4.py-3.text-left.bg-gray-50.border-b.border-gray-200', { style: 'width: 40px;' }, [
1724
+ m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
1725
+ checked: state.records.length > 0 && state.selectedRecords && state.selectedRecords.size === state.records.length,
1726
+ indeterminate: state.selectedRecords && state.selectedRecords.size > 0 && state.selectedRecords.size < state.records.length,
1727
+ onchange: (e) => {
1728
+ if (e.target.checked) {
1729
+ state.selectedRecords = new Set(state.records.map(r => r[primaryKey]));
1730
+ } else {
1731
+ state.selectedRecords = new Set();
1732
+ }
1733
+ m.redraw();
1734
+ },
1735
+ }),
1736
+ ]),
1610
1737
  // Dynamic column headers
1611
1738
  ...displayColumns.map(col =>
1612
1739
  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 +1747,24 @@ const RecordList = {
1620
1747
  ]),
1621
1748
  ]),
1622
1749
  m('tbody.divide-y.divide-gray-100', state.records.map(record =>
1623
- m('tr.hover:bg-gray-50.transition-colors', [
1750
+ m('tr.hover:bg-gray-50.transition-colors', {
1751
+ class: state.selectedRecords && state.selectedRecords.has(record[primaryKey]) ? 'bg-indigo-50' : '',
1752
+ }, [
1753
+ // Checkbox cell
1754
+ m('td.px-4.py-3', [
1755
+ m('input[type=checkbox].rounded.border-gray-300.text-indigo-600.focus:ring-indigo-500', {
1756
+ checked: state.selectedRecords && state.selectedRecords.has(record[primaryKey]),
1757
+ onchange: (e) => {
1758
+ if (!state.selectedRecords) state.selectedRecords = new Set();
1759
+ if (e.target.checked) {
1760
+ state.selectedRecords.add(record[primaryKey]);
1761
+ } else {
1762
+ state.selectedRecords.delete(record[primaryKey]);
1763
+ }
1764
+ m.redraw();
1765
+ },
1766
+ }),
1767
+ ]),
1624
1768
  // Dynamic cell values
1625
1769
  ...displayColumns.map(col =>
1626
1770
  m('td.px-4.py-3.text-sm.whitespace-nowrap.text-gray-700',