domma-cms 0.13.4 → 0.13.7
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/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/templates/page-editor.html +1 -1
- package/admin/js/views/layouts.js +1 -1
- package/admin/js/views/page-editor.js +34 -33
- package/config/navigation.json +5 -0
- package/config/plugins.json +5 -5
- package/config/presets.json +92 -40
- package/config/site.json +75 -8
- package/package.json +2 -1
- package/public/css/site.css +1 -1
- package/scripts/users.js +318 -0
- package/server/routes/api/layouts.js +24 -0
- package/server/server.js +2 -0
- package/server/services/renderer.js +3 -1
package/scripts/users.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Domma CMS — User Management CLI
|
|
4
|
+
*
|
|
5
|
+
* Run from the project root:
|
|
6
|
+
* npm run users -- <command> [args] [options]
|
|
7
|
+
* node scripts/users.js <command> [args] [options]
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* list Print all users as a table
|
|
11
|
+
* show <email> Print one user's record
|
|
12
|
+
* reset-password <email> Set a new password (prompts if --password omitted)
|
|
13
|
+
* set-role <email> <role> Change a user's role
|
|
14
|
+
* activate <email> Mark user as active
|
|
15
|
+
* deactivate <email> Mark user as inactive
|
|
16
|
+
* create <email> Create a new user (prompts for password unless --password/--generate)
|
|
17
|
+
* delete <email> Delete a user (confirmation required unless --yes)
|
|
18
|
+
*
|
|
19
|
+
* Options:
|
|
20
|
+
* --password=PW Supply password inline (skips prompt; visible in shell history)
|
|
21
|
+
* --generate Generate a strong random password and print it once
|
|
22
|
+
* --name=NAME Display name for `create`
|
|
23
|
+
* --role=ROLE Role for `create` (default: user)
|
|
24
|
+
* --yes Skip confirmation for destructive operations
|
|
25
|
+
*/
|
|
26
|
+
import {createInterface} from 'node:readline/promises';
|
|
27
|
+
import {randomBytes} from 'node:crypto';
|
|
28
|
+
import {existsSync} from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import {fileURLToPath} from 'node:url';
|
|
31
|
+
|
|
32
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
34
|
+
|
|
35
|
+
if (!existsSync(path.join(process.cwd(), 'config', 'content.json'))) {
|
|
36
|
+
console.error('\n ✗ Run this command from the project root (no config/content.json found in CWD).\n');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
clearResetToken,
|
|
42
|
+
createUser,
|
|
43
|
+
deleteUser,
|
|
44
|
+
getUserByEmail,
|
|
45
|
+
listUsers,
|
|
46
|
+
updateUser
|
|
47
|
+
} = await import(path.join(ROOT, 'server/services/users.js'));
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Arg parsing
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const argv = process.argv.slice(2);
|
|
54
|
+
const positional = argv.filter(a => !a.startsWith('--'));
|
|
55
|
+
const flags = new Map(
|
|
56
|
+
argv.filter(a => a.startsWith('--')).map(a => {
|
|
57
|
+
const eq = a.indexOf('=');
|
|
58
|
+
return eq === -1 ? [a.slice(2), true] : [a.slice(2, eq), a.slice(eq + 1)];
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const command = positional[0];
|
|
63
|
+
|
|
64
|
+
if (!command || flags.has('help')) {
|
|
65
|
+
printHelp();
|
|
66
|
+
process.exit(command ? 0 : 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Command dispatch
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
switch (command) {
|
|
75
|
+
case 'list': await cmdList(); break;
|
|
76
|
+
case 'show': await cmdShow(positional[1]); break;
|
|
77
|
+
case 'reset-password': await cmdResetPassword(positional[1]); break;
|
|
78
|
+
case 'set-role': await cmdSetRole(positional[1], positional[2]); break;
|
|
79
|
+
case 'activate': await cmdSetActive(positional[1], true); break;
|
|
80
|
+
case 'deactivate': await cmdSetActive(positional[1], false); break;
|
|
81
|
+
case 'create': await cmdCreate(positional[1]); break;
|
|
82
|
+
case 'delete': await cmdDelete(positional[1]); break;
|
|
83
|
+
default:
|
|
84
|
+
console.error(`\n ✗ Unknown command: ${command}\n`);
|
|
85
|
+
printHelp();
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`\n ✗ ${err.message}\n`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Commands
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
async function cmdList() {
|
|
98
|
+
const users = await listUsers();
|
|
99
|
+
if (!users.length) {
|
|
100
|
+
console.log('\n No users found.\n');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const rows = users.map(u => ({
|
|
104
|
+
email: u.email,
|
|
105
|
+
name: u.name,
|
|
106
|
+
role: u.role,
|
|
107
|
+
active: u.isActive ? 'yes' : 'no',
|
|
108
|
+
last: u.lastLogin ? u.lastLogin.slice(0, 16).replace('T', ' ') : '—'
|
|
109
|
+
}));
|
|
110
|
+
printTable(['email', 'name', 'role', 'active', 'last'], rows);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function cmdShow(email) {
|
|
114
|
+
const user = await requireUser(email);
|
|
115
|
+
const {password, resetTokenHash, ...safe} = user;
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(JSON.stringify(safe, null, 2));
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function cmdResetPassword(email) {
|
|
122
|
+
const user = await requireUser(email);
|
|
123
|
+
const password = await resolvePassword({confirm: true});
|
|
124
|
+
|
|
125
|
+
await updateUser(user.id, {password});
|
|
126
|
+
// Invalidate any outstanding reset token so the old email link can't be used afterwards.
|
|
127
|
+
if (user.resetTokenHash) {
|
|
128
|
+
await clearResetToken(user.id);
|
|
129
|
+
}
|
|
130
|
+
console.log(`\n ✓ Password updated for ${user.email}.\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function cmdSetRole(email, role) {
|
|
134
|
+
if (!role) throw new Error('Usage: set-role <email> <role>');
|
|
135
|
+
const user = await requireUser(email);
|
|
136
|
+
await updateUser(user.id, {role});
|
|
137
|
+
console.log(`\n ✓ ${user.email} → role set to "${role}".\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function cmdSetActive(email, active) {
|
|
141
|
+
const user = await requireUser(email);
|
|
142
|
+
await updateUser(user.id, {isActive: active});
|
|
143
|
+
console.log(`\n ✓ ${user.email} → ${active ? 'activated' : 'deactivated'}.\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function cmdCreate(email) {
|
|
147
|
+
if (!email) throw new Error('Usage: create <email> [--name=N] [--role=R] [--password=P|--generate]');
|
|
148
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
149
|
+
throw new Error('Invalid email address');
|
|
150
|
+
}
|
|
151
|
+
const existing = await getUserByEmail(email);
|
|
152
|
+
if (existing) throw new Error(`A user with that email already exists (${existing.id})`);
|
|
153
|
+
|
|
154
|
+
const name = flags.get('name') || email.split('@')[0];
|
|
155
|
+
const role = flags.get('role') || 'user';
|
|
156
|
+
const password = await resolvePassword({confirm: true});
|
|
157
|
+
|
|
158
|
+
const user = await createUser({name, email, password, role});
|
|
159
|
+
console.log(`\n ✓ Created user ${user.email} (id: ${user.id}, role: ${user.role}).\n`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function cmdDelete(email) {
|
|
163
|
+
const user = await requireUser(email);
|
|
164
|
+
|
|
165
|
+
if (!flags.has('yes')) {
|
|
166
|
+
const ok = await confirm(`Delete ${user.email} (${user.id})?`);
|
|
167
|
+
if (!ok) {
|
|
168
|
+
console.log('\n Cancelled.\n');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await deleteUser(user.id);
|
|
174
|
+
console.log(`\n ✓ Deleted ${user.email}.\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Helpers
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
async function requireUser(email) {
|
|
182
|
+
if (!email) throw new Error('Email argument is required');
|
|
183
|
+
const user = await getUserByEmail(email);
|
|
184
|
+
if (!user) throw new Error(`No user found with email: ${email}`);
|
|
185
|
+
return user;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a password from flags or interactive prompt.
|
|
190
|
+
*
|
|
191
|
+
* Precedence: --password=… → --generate → hidden prompt.
|
|
192
|
+
* Generated passwords are printed once so the operator can capture them.
|
|
193
|
+
*
|
|
194
|
+
* @param {{ confirm?: boolean }} [opts]
|
|
195
|
+
* @returns {Promise<string>}
|
|
196
|
+
*/
|
|
197
|
+
async function resolvePassword({confirm: requireConfirm = false} = {}) {
|
|
198
|
+
if (typeof flags.get('password') === 'string') {
|
|
199
|
+
const pw = flags.get('password');
|
|
200
|
+
if (pw.length < 8) throw new Error('Password must be at least 8 characters');
|
|
201
|
+
return pw;
|
|
202
|
+
}
|
|
203
|
+
if (flags.has('generate')) {
|
|
204
|
+
const pw = generatePassword(20);
|
|
205
|
+
console.log(`\n Generated password: ${pw}`);
|
|
206
|
+
console.log(' (this is the only time it will be shown — copy it now)\n');
|
|
207
|
+
return pw;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const pw = await promptHidden(' New password: ');
|
|
211
|
+
if (pw.length < 8) throw new Error('Password must be at least 8 characters');
|
|
212
|
+
if (requireConfirm) {
|
|
213
|
+
const pw2 = await promptHidden(' Confirm: ');
|
|
214
|
+
if (pw !== pw2) throw new Error('Passwords do not match');
|
|
215
|
+
}
|
|
216
|
+
return pw;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function generatePassword(bytes) {
|
|
220
|
+
return randomBytes(bytes).toString('base64url').slice(0, bytes);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Prompt with masked echo — reads raw TTY input so keystrokes don't appear on screen.
|
|
225
|
+
* Falls back to a plain (visible) readline when stdin isn't a TTY (e.g. piped input).
|
|
226
|
+
*
|
|
227
|
+
* @param {string} prompt
|
|
228
|
+
* @returns {Promise<string>}
|
|
229
|
+
*/
|
|
230
|
+
function promptHidden(prompt) {
|
|
231
|
+
if (!process.stdin.isTTY) {
|
|
232
|
+
const rl = createInterface({input: process.stdin, output: process.stdout});
|
|
233
|
+
return rl.question(prompt).then(v => { rl.close(); return v; });
|
|
234
|
+
}
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
process.stdout.write(prompt);
|
|
237
|
+
const stdin = process.stdin;
|
|
238
|
+
stdin.setRawMode(true);
|
|
239
|
+
stdin.resume();
|
|
240
|
+
stdin.setEncoding('utf8');
|
|
241
|
+
|
|
242
|
+
let buffer = '';
|
|
243
|
+
const onData = (ch) => {
|
|
244
|
+
switch (ch) {
|
|
245
|
+
case '\n': case '\r': case '\u0004':
|
|
246
|
+
stdin.setRawMode(false);
|
|
247
|
+
stdin.pause();
|
|
248
|
+
stdin.removeListener('data', onData);
|
|
249
|
+
process.stdout.write('\n');
|
|
250
|
+
resolve(buffer);
|
|
251
|
+
return;
|
|
252
|
+
case '\u0003': // Ctrl-C
|
|
253
|
+
stdin.setRawMode(false);
|
|
254
|
+
stdin.pause();
|
|
255
|
+
stdin.removeListener('data', onData);
|
|
256
|
+
process.stdout.write('\n');
|
|
257
|
+
reject(new Error('Cancelled'));
|
|
258
|
+
return;
|
|
259
|
+
case '\u007f': case '\b': // backspace
|
|
260
|
+
if (buffer.length) buffer = buffer.slice(0, -1);
|
|
261
|
+
return;
|
|
262
|
+
default:
|
|
263
|
+
if (ch >= ' ') buffer += ch;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
stdin.on('data', onData);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function confirm(question) {
|
|
271
|
+
const rl = createInterface({input: process.stdin, output: process.stdout});
|
|
272
|
+
const answer = (await rl.question(` ${question} Type "yes" to confirm: `)).trim();
|
|
273
|
+
rl.close();
|
|
274
|
+
return answer === 'yes';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function printTable(headers, rows) {
|
|
278
|
+
const widths = headers.map(h => Math.max(h.length, ...rows.map(r => String(r[h] ?? '').length)));
|
|
279
|
+
const fmt = (cells) => ' ' + cells.map((c, i) => String(c).padEnd(widths[i])).join(' ');
|
|
280
|
+
console.log('');
|
|
281
|
+
console.log(fmt(headers));
|
|
282
|
+
console.log(fmt(widths.map(w => '─'.repeat(w))));
|
|
283
|
+
rows.forEach(r => console.log(fmt(headers.map(h => r[h] ?? ''))));
|
|
284
|
+
console.log('');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function printHelp() {
|
|
288
|
+
console.log(`
|
|
289
|
+
Domma CMS — User Management CLI
|
|
290
|
+
|
|
291
|
+
Usage:
|
|
292
|
+
npm run users -- <command> [args] [options]
|
|
293
|
+
|
|
294
|
+
Commands:
|
|
295
|
+
list List all users
|
|
296
|
+
show <email> Print one user's record (no password hash)
|
|
297
|
+
reset-password <email> Set a new password (prompts unless --password/--generate)
|
|
298
|
+
set-role <email> <role> Change a user's role
|
|
299
|
+
activate <email> Mark user active
|
|
300
|
+
deactivate <email> Mark user inactive
|
|
301
|
+
create <email> Create a user (requires a password)
|
|
302
|
+
delete <email> Delete a user (prompts unless --yes)
|
|
303
|
+
|
|
304
|
+
Options:
|
|
305
|
+
--password=PW Supply password inline (visible in shell history)
|
|
306
|
+
--generate Generate a strong random password and print it
|
|
307
|
+
--name=NAME Display name (for create)
|
|
308
|
+
--role=ROLE Role (for create; default: user)
|
|
309
|
+
--yes Skip confirmation for destructive operations
|
|
310
|
+
|
|
311
|
+
Examples:
|
|
312
|
+
npm run users -- list
|
|
313
|
+
npm run users -- reset-password admin@example.com
|
|
314
|
+
npm run users -- reset-password admin@example.com --generate
|
|
315
|
+
npm run users -- create editor@example.com --name="Editor" --role=editor
|
|
316
|
+
npm run users -- delete old@example.com --yes
|
|
317
|
+
`);
|
|
318
|
+
}
|
|
@@ -22,12 +22,36 @@ const BUILTIN_PRESETS = {
|
|
|
22
22
|
builtin: true, navbar: true, footer: true, sidebar: false,
|
|
23
23
|
width: 'normal', bgColor: '', bgImage: '', class: ''
|
|
24
24
|
},
|
|
25
|
+
article: {
|
|
26
|
+
key: 'article', label: 'Article',
|
|
27
|
+
description: 'Narrow reading column for long-form prose and blog posts.',
|
|
28
|
+
builtin: true, navbar: true, footer: true, sidebar: false,
|
|
29
|
+
width: 'narrow', bgColor: '', bgImage: '', class: ''
|
|
30
|
+
},
|
|
31
|
+
wide: {
|
|
32
|
+
key: 'wide', label: 'Wide',
|
|
33
|
+
description: 'Standard page with navbar and footer, using a wide content column (~75% of viewport).',
|
|
34
|
+
builtin: true, navbar: true, footer: true, sidebar: false,
|
|
35
|
+
width: 'wide', bgColor: '', bgImage: '', class: ''
|
|
36
|
+
},
|
|
25
37
|
landing: {
|
|
26
38
|
key: 'landing', label: 'Landing Page',
|
|
27
39
|
description: 'Full-width landing page with navbar, no footer.',
|
|
28
40
|
builtin: true, navbar: true, footer: false, sidebar: false,
|
|
29
41
|
width: 'full', bgColor: '', bgImage: '', class: ''
|
|
30
42
|
},
|
|
43
|
+
product: {
|
|
44
|
+
key: 'product', label: 'Product Page',
|
|
45
|
+
description: 'Full-width marketing page that keeps the footer.',
|
|
46
|
+
builtin: true, navbar: true, footer: true, sidebar: false,
|
|
47
|
+
width: 'full', bgColor: '', bgImage: '', class: ''
|
|
48
|
+
},
|
|
49
|
+
dashboard: {
|
|
50
|
+
key: 'dashboard', label: 'Dashboard',
|
|
51
|
+
description: 'Wide layout for data-heavy or app-like pages; no footer.',
|
|
52
|
+
builtin: true, navbar: true, footer: false, sidebar: false,
|
|
53
|
+
width: 'wide', bgColor: '', bgImage: '', class: ''
|
|
54
|
+
},
|
|
31
55
|
blank: {
|
|
32
56
|
key: 'blank', label: 'Blank',
|
|
33
57
|
description: 'Minimal page with no navbar or footer.',
|
package/server/server.js
CHANGED
|
@@ -243,6 +243,7 @@ const { actionsRoutes } = await import('./routes/api/actions.js');
|
|
|
243
243
|
const {blocksRoutes} = await import('./routes/api/blocks.js');
|
|
244
244
|
const {versionsRoutes} = await import('./routes/api/versions.js');
|
|
245
245
|
const {effectsRoutes} = await import('./routes/api/effects.js');
|
|
246
|
+
const {notificationsRoutes} = await import('./routes/api/notifications.js');
|
|
246
247
|
|
|
247
248
|
await app.register(pagesRoutes, { prefix: '/api' });
|
|
248
249
|
await app.register(settingsRoutes, { prefix: '/api' });
|
|
@@ -259,6 +260,7 @@ await app.register(actionsRoutes, { prefix: '/api' });
|
|
|
259
260
|
await app.register(blocksRoutes, {prefix: '/api'});
|
|
260
261
|
await app.register(versionsRoutes, {prefix: '/api'});
|
|
261
262
|
await app.register(effectsRoutes, {prefix: '/api'});
|
|
263
|
+
await app.register(notificationsRoutes, {prefix: '/api'});
|
|
262
264
|
|
|
263
265
|
// ---------------------------------------------------------------------------
|
|
264
266
|
// CMS Plugins (server-side Fastify plugins from plugins/ directory)
|
|
@@ -69,7 +69,9 @@ export async function renderPage(page) {
|
|
|
69
69
|
|
|
70
70
|
const preset = presets[page.layout] || presets['default'] || {};
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// Per-page `width:` frontmatter overrides the preset's width when valid.
|
|
73
|
+
const pageWidth = VALID_LAYOUT_WIDTHS.has(page.width) ? page.width : null;
|
|
74
|
+
const layoutWidth = pageWidth || (VALID_LAYOUT_WIDTHS.has(preset.width) ? preset.width : 'normal');
|
|
73
75
|
const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || ''].filter(Boolean).join(' ');
|
|
74
76
|
const pageBodyStyleParts = [];
|
|
75
77
|
const BG_COLOR_SAFE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
|