domma-cms 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CLAUDE.md +36 -2
  2. package/admin/js/templates/collection-editor.html +1 -1
  3. package/admin/js/templates/effects.html +752 -752
  4. package/admin/js/templates/forms.html +17 -17
  5. package/admin/js/templates/my-profile.html +17 -17
  6. package/admin/js/templates/role-editor.html +70 -70
  7. package/admin/js/templates/roles.html +10 -10
  8. package/admin/js/views/action-editor.js +1 -1
  9. package/admin/js/views/block-editor.js +2 -2
  10. package/admin/js/views/collection-editor.js +4 -4
  11. package/admin/js/views/form-editor.js +6 -6
  12. package/admin/js/views/navigation.js +16 -16
  13. package/admin/js/views/page-editor.js +41 -41
  14. package/admin/js/views/view-editor.js +1 -1
  15. package/bin/lib/config-merge.js +44 -44
  16. package/config/plugins.json +1 -1
  17. package/config/site.json +86 -86
  18. package/package.json +1 -1
  19. package/public/css/site.css +1 -1
  20. package/public/js/collection-context.js +2 -2
  21. package/public/js/collection-export.mjs +3 -0
  22. package/public/js/collection-query.mjs +1 -0
  23. package/public/js/collection-sort.mjs +1 -0
  24. package/public/js/site.js +1 -1
  25. package/scripts/build.js +94 -0
  26. package/scripts/setup.js +23 -2
  27. package/server/middleware/auth.js +253 -253
  28. package/server/routes/api/auth.js +309 -309
  29. package/server/routes/api/navigation.js +42 -42
  30. package/server/services/collections.js +12 -11
  31. package/server/services/email.js +167 -167
  32. package/server/services/markdown.js +106 -33
  33. package/server/services/userProfiles.js +199 -199
  34. package/server/services/users.js +302 -302
  35. package/server/templates/page.html +1 -1
  36. package/config/navigation.json.bak +0 -31
@@ -1,42 +1,42 @@
1
- /**
2
- * Navigation API
3
- * GET /api/navigation - get navigation config
4
- * PUT /api/navigation - save navigation config
5
- */
6
- import {getConfig, saveConfig} from '../../config.js';
7
- import {authenticate, requirePermission} from '../../middleware/auth.js';
8
- import * as cache from '../../services/cache/index.js';
9
-
10
- export async function navigationRoutes(fastify) {
11
- const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
12
- const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
13
-
14
- fastify.get('/navigation', canRead, async () => {
15
- return getConfig('navigation');
16
- });
17
-
18
- fastify.put('/navigation', canUpdate, async (request, reply) => {
19
- const data = request.body;
20
- if (!data || typeof data !== 'object') {
21
- return reply.status(400).send({ error: 'Invalid navigation data' });
22
- }
23
- if (!Array.isArray(data.items) && !Array.isArray(data)) {
24
- return reply.status(400).send({ error: 'Navigation must be an array of items' });
25
- }
26
- // Normalise child key: Domma navbar expects `items`, not `children`
27
- if (Array.isArray(data.items)) {
28
- data.items = data.items.map(item => {
29
- const children = item.items || item.children;
30
- if (children?.length) {
31
- const { children: _c, ...rest } = item;
32
- return { ...rest, items: children };
33
- }
34
- const { children: _c, ...rest } = item;
35
- return rest;
36
- });
37
- }
38
- saveConfig('navigation', data);
39
- await cache.invalidateTags(['nav']);
40
- return { success: true };
41
- });
42
- }
1
+ /**
2
+ * Navigation API
3
+ * GET /api/navigation - get navigation config
4
+ * PUT /api/navigation - save navigation config
5
+ */
6
+ import {getConfig, saveConfig} from '../../config.js';
7
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
8
+ import * as cache from '../../services/cache/index.js';
9
+
10
+ export async function navigationRoutes(fastify) {
11
+ const canRead = {preHandler: [authenticate, requirePermission('navigation', 'read')]};
12
+ const canUpdate = {preHandler: [authenticate, requirePermission('navigation', 'update')]};
13
+
14
+ fastify.get('/navigation', canRead, async () => {
15
+ return getConfig('navigation');
16
+ });
17
+
18
+ fastify.put('/navigation', canUpdate, async (request, reply) => {
19
+ const data = request.body;
20
+ if (!data || typeof data !== 'object') {
21
+ return reply.status(400).send({ error: 'Invalid navigation data' });
22
+ }
23
+ if (!Array.isArray(data.items) && !Array.isArray(data)) {
24
+ return reply.status(400).send({ error: 'Navigation must be an array of items' });
25
+ }
26
+ // Normalise child key: Domma navbar expects `items`, not `children`
27
+ if (Array.isArray(data.items)) {
28
+ data.items = data.items.map(item => {
29
+ const children = item.items || item.children;
30
+ if (children?.length) {
31
+ const { children: _c, ...rest } = item;
32
+ return { ...rest, items: children };
33
+ }
34
+ const { children: _c, ...rest } = item;
35
+ return rest;
36
+ });
37
+ }
38
+ saveConfig('navigation', data);
39
+ await cache.invalidateTags(['nav']);
40
+ return { success: true };
41
+ });
42
+ }
@@ -12,6 +12,7 @@ import {v4 as uuidv4} from 'uuid';
12
12
  import {config} from '../config.js';
13
13
  import {getAdapter, invalidate} from './adapterRegistry.js';
14
14
  import * as cache from './cache/index.js';
15
+ import {toCsv} from '../../public/js/collection-export.mjs';
15
16
 
16
17
  const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
17
18
 
@@ -480,17 +481,17 @@ export async function exportEntries(slug, format = 'json') {
480
481
  const entries = await adapter.all(slug);
481
482
 
482
483
  if (format === 'csv') {
483
- const fields = schema.fields.map(f => f.name);
484
- const header = ['id', ...fields, 'createdAt'].join(',');
485
- const rows = entries.map(e => {
486
- const cells = ['id', ...fields, 'createdAt'].map(key => {
487
- const val = key === 'id' ? e.id : key === 'createdAt' ? (e.meta?.createdAt || '') : (e.data?.[key] ?? '');
488
- const str = String(val).replace(/"/g, '""');
489
- return `"${str}"`;
490
- });
491
- return cells.join(',');
492
- });
493
- return [header, ...rows].join('\n');
484
+ const columns = [
485
+ {name: 'id'},
486
+ ...schema.fields.map(f => ({name: f.name})),
487
+ {name: 'createdAt'}
488
+ ];
489
+ const rows = entries.map(e => ({
490
+ id: e.id,
491
+ ...Object.fromEntries(schema.fields.map(f => [f.name, e.data?.[f.name]])),
492
+ createdAt: e.meta?.createdAt || ''
493
+ }));
494
+ return toCsv(rows, columns);
494
495
  }
495
496
 
496
497
  return JSON.stringify(entries, null, 2);
@@ -1,167 +1,167 @@
1
- /**
2
- * Core Email Utility
3
- * Nodemailer transport factory and generic form email sending utility.
4
- */
5
- import nodemailer from 'nodemailer';
6
-
7
- let lastSendResult = null;
8
-
9
- /**
10
- * Return the most recent email send result, or null if no send has occurred.
11
- *
12
- * @returns {{ ok: boolean, at: string, info: string|null } | null}
13
- */
14
- export function getLastSendResult() {
15
- return lastSendResult;
16
- }
17
-
18
- /**
19
- * Record the outcome of an email send for health reporting.
20
- *
21
- * @param {boolean} ok
22
- * @param {string|null} info
23
- * @returns {void}
24
- */
25
- function recordSendResult(ok, info) {
26
- lastSendResult = {
27
- ok,
28
- at: new Date().toISOString(),
29
- info: info || null
30
- };
31
- }
32
-
33
- /**
34
- * Escape HTML special characters for safe use in email bodies.
35
- *
36
- * @param {string} str
37
- * @returns {string}
38
- */
39
- export function escapeHtml(str) {
40
- return String(str)
41
- .replace(/&/g, '&')
42
- .replace(/</g, '&lt;')
43
- .replace(/>/g, '&gt;')
44
- .replace(/"/g, '&quot;')
45
- .replace(/'/g, '&#39;');
46
- }
47
-
48
- /**
49
- * Create a nodemailer transport.
50
- * Falls back to an Ethereal test account when no SMTP host is configured.
51
- *
52
- * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
53
- * @returns {Promise<import('nodemailer').Transporter>}
54
- * @throws {Error} If Ethereal test account creation fails.
55
- */
56
- export async function createTransport(smtp) {
57
- if (smtp?.host) {
58
- return nodemailer.createTransport({
59
- host: smtp.host,
60
- port: smtp.port || 587,
61
- secure: smtp.secure || false,
62
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
63
- tls: { rejectUnauthorized: false }
64
- });
65
- }
66
-
67
- // No SMTP configured — use Ethereal for dev/demo
68
- const testAccount = await nodemailer.createTestAccount();
69
- console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
70
- return nodemailer.createTransport({
71
- host: 'smtp.ethereal.email',
72
- port: 587,
73
- secure: false,
74
- auth: { user: testAccount.user, pass: testAccount.pass }
75
- });
76
- }
77
-
78
- /**
79
- * Send a generic transactional email.
80
- *
81
- * @param {import('nodemailer').Transporter} transport
82
- * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
83
- * @returns {Promise<void>}
84
- * @throws {Error} If sending the email fails.
85
- */
86
- export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
87
- try {
88
- const info = await transport.sendMail({
89
- from: `"${fromName}" <${from}>`,
90
- to,
91
- subject,
92
- text,
93
- html
94
- });
95
- recordSendResult(true, info.messageId);
96
-
97
- const previewUrl = nodemailer.getTestMessageUrl(info);
98
- if (previewUrl) {
99
- console.log('[email] Preview URL:', previewUrl);
100
- }
101
- return info;
102
- } catch (err) {
103
- recordSendResult(false, err.message);
104
- throw err;
105
- }
106
- }
107
-
108
- /**
109
- * Send an HTML + plain-text form submission notification email.
110
- * Builds a generic table of field→value pairs from the submitted data.
111
- *
112
- * @param {import('nodemailer').Transporter} transport
113
- * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
114
- * @returns {Promise<void>}
115
- * @throws {Error} If sending the email fails.
116
- */
117
- export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
118
- const rows = fields.map(field => {
119
- const val = data[field.name] ?? '';
120
- const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
121
- const safeLabel = escapeHtml(field.label || field.name);
122
- return `
123
- <tr>
124
- <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
125
- <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
126
- </tr>`.trim();
127
- }).join('\n');
128
-
129
- const plainRows = fields.map(field => {
130
- const val = data[field.name] ?? '';
131
- return `${field.label || field.name}: ${val}`;
132
- }).join('\n');
133
-
134
- const html = `
135
- <!DOCTYPE html>
136
- <html>
137
- <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
138
- <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
139
- <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
140
- <table style="width:100%;border-collapse:collapse;margin-top:16px;">
141
- ${rows}
142
- </table>
143
- </body>
144
- </html>`.trim();
145
-
146
- const text = `New form submission: ${formTitle}\n\n${plainRows}`;
147
-
148
- try {
149
- const info = await transport.sendMail({
150
- from: `"${fromName}" <${from}>`,
151
- to,
152
- subject,
153
- text,
154
- html
155
- });
156
- recordSendResult(true, info.messageId);
157
-
158
- const previewUrl = nodemailer.getTestMessageUrl(info);
159
- if (previewUrl) {
160
- console.log('[email] Preview URL:', previewUrl);
161
- }
162
- return info;
163
- } catch (err) {
164
- recordSendResult(false, err.message);
165
- throw err;
166
- }
167
- }
1
+ /**
2
+ * Core Email Utility
3
+ * Nodemailer transport factory and generic form email sending utility.
4
+ */
5
+ import nodemailer from 'nodemailer';
6
+
7
+ let lastSendResult = null;
8
+
9
+ /**
10
+ * Return the most recent email send result, or null if no send has occurred.
11
+ *
12
+ * @returns {{ ok: boolean, at: string, info: string|null } | null}
13
+ */
14
+ export function getLastSendResult() {
15
+ return lastSendResult;
16
+ }
17
+
18
+ /**
19
+ * Record the outcome of an email send for health reporting.
20
+ *
21
+ * @param {boolean} ok
22
+ * @param {string|null} info
23
+ * @returns {void}
24
+ */
25
+ function recordSendResult(ok, info) {
26
+ lastSendResult = {
27
+ ok,
28
+ at: new Date().toISOString(),
29
+ info: info || null
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Escape HTML special characters for safe use in email bodies.
35
+ *
36
+ * @param {string} str
37
+ * @returns {string}
38
+ */
39
+ export function escapeHtml(str) {
40
+ return String(str)
41
+ .replace(/&/g, '&amp;')
42
+ .replace(/</g, '&lt;')
43
+ .replace(/>/g, '&gt;')
44
+ .replace(/"/g, '&quot;')
45
+ .replace(/'/g, '&#39;');
46
+ }
47
+
48
+ /**
49
+ * Create a nodemailer transport.
50
+ * Falls back to an Ethereal test account when no SMTP host is configured.
51
+ *
52
+ * @param {{ host: string, port: number, secure: boolean, user: string, pass: string }} smtp
53
+ * @returns {Promise<import('nodemailer').Transporter>}
54
+ * @throws {Error} If Ethereal test account creation fails.
55
+ */
56
+ export async function createTransport(smtp) {
57
+ if (smtp?.host) {
58
+ return nodemailer.createTransport({
59
+ host: smtp.host,
60
+ port: smtp.port || 587,
61
+ secure: smtp.secure || false,
62
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
63
+ tls: { rejectUnauthorized: false }
64
+ });
65
+ }
66
+
67
+ // No SMTP configured — use Ethereal for dev/demo
68
+ const testAccount = await nodemailer.createTestAccount();
69
+ console.log('[email] No SMTP configured. Using Ethereal test account:', testAccount.user);
70
+ return nodemailer.createTransport({
71
+ host: 'smtp.ethereal.email',
72
+ port: 587,
73
+ secure: false,
74
+ auth: { user: testAccount.user, pass: testAccount.pass }
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Send a generic transactional email.
80
+ *
81
+ * @param {import('nodemailer').Transporter} transport
82
+ * @param {{ from: string, fromName: string, to: string, subject: string, html: string, text: string }} opts
83
+ * @returns {Promise<void>}
84
+ * @throws {Error} If sending the email fails.
85
+ */
86
+ export async function sendEmail(transport, {from, fromName, to, subject, html, text}) {
87
+ try {
88
+ const info = await transport.sendMail({
89
+ from: `"${fromName}" <${from}>`,
90
+ to,
91
+ subject,
92
+ text,
93
+ html
94
+ });
95
+ recordSendResult(true, info.messageId);
96
+
97
+ const previewUrl = nodemailer.getTestMessageUrl(info);
98
+ if (previewUrl) {
99
+ console.log('[email] Preview URL:', previewUrl);
100
+ }
101
+ return info;
102
+ } catch (err) {
103
+ recordSendResult(false, err.message);
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Send an HTML + plain-text form submission notification email.
110
+ * Builds a generic table of field→value pairs from the submitted data.
111
+ *
112
+ * @param {import('nodemailer').Transporter} transport
113
+ * @param {{ from: string, fromName: string, to: string, subject: string, formTitle: string, fields: Array<{name: string, label: string}>, data: Record<string, unknown> }} opts
114
+ * @returns {Promise<void>}
115
+ * @throws {Error} If sending the email fails.
116
+ */
117
+ export async function sendFormEmail(transport, { from, fromName, to, subject, formTitle, fields, data }) {
118
+ const rows = fields.map(field => {
119
+ const val = data[field.name] ?? '';
120
+ const safe = escapeHtml(String(val)).replace(/\n/g, '<br>');
121
+ const safeLabel = escapeHtml(field.label || field.name);
122
+ return `
123
+ <tr>
124
+ <td style="padding:8px 12px;font-weight:600;background:#f9f9f9;border:1px solid #eee;white-space:nowrap;vertical-align:top;">${safeLabel}</td>
125
+ <td style="padding:8px 12px;border:1px solid #eee;vertical-align:top;">${safe}</td>
126
+ </tr>`.trim();
127
+ }).join('\n');
128
+
129
+ const plainRows = fields.map(field => {
130
+ const val = data[field.name] ?? '';
131
+ return `${field.label || field.name}: ${val}`;
132
+ }).join('\n');
133
+
134
+ const html = `
135
+ <!DOCTYPE html>
136
+ <html>
137
+ <body style="font-family:sans-serif;max-width:640px;margin:0 auto;padding:20px;">
138
+ <h2 style="color:#333;margin-bottom:4px;">New Form Submission</h2>
139
+ <p style="color:#888;margin-top:0;font-size:.9rem;">${escapeHtml(formTitle)}</p>
140
+ <table style="width:100%;border-collapse:collapse;margin-top:16px;">
141
+ ${rows}
142
+ </table>
143
+ </body>
144
+ </html>`.trim();
145
+
146
+ const text = `New form submission: ${formTitle}\n\n${plainRows}`;
147
+
148
+ try {
149
+ const info = await transport.sendMail({
150
+ from: `"${fromName}" <${from}>`,
151
+ to,
152
+ subject,
153
+ text,
154
+ html
155
+ });
156
+ recordSendResult(true, info.messageId);
157
+
158
+ const previewUrl = nodemailer.getTestMessageUrl(info);
159
+ if (previewUrl) {
160
+ console.log('[email] Preview URL:', previewUrl);
161
+ }
162
+ return info;
163
+ } catch (err) {
164
+ recordSendResult(false, err.message);
165
+ throw err;
166
+ }
167
+ }