domma-cms 0.22.6 → 0.24.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 (45) hide show
  1. package/CLAUDE.md +16 -5
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/crud-tutorial.js +1 -1
  5. package/admin/js/lib/project-context.js +1 -1
  6. package/admin/js/templates/api-tokens.html +13 -0
  7. package/admin/js/templates/effects.html +752 -752
  8. package/admin/js/templates/form-submissions.html +30 -30
  9. package/admin/js/templates/forms.html +17 -17
  10. package/admin/js/templates/my-profile.html +17 -17
  11. package/admin/js/templates/project-settings.html +1 -1
  12. package/admin/js/templates/role-editor.html +70 -70
  13. package/admin/js/templates/roles.html +10 -10
  14. package/admin/js/views/api-tokens.js +8 -0
  15. package/admin/js/views/collection-editor.js +4 -4
  16. package/admin/js/views/index.js +1 -1
  17. package/admin/js/views/project-settings.js +1 -1
  18. package/admin/js/views/projects.js +3 -3
  19. package/admin/js/views/roles.js +1 -1
  20. package/bin/lib/config-merge.js +44 -44
  21. package/bin/update.js +547 -547
  22. package/config/menus/admin-sidebar.json +7 -1
  23. package/package.json +3 -2
  24. package/server/middleware/auth.js +253 -253
  25. package/server/routes/api/api-tokens.js +83 -0
  26. package/server/routes/api/auth.js +309 -309
  27. package/server/routes/api/collections.js +113 -16
  28. package/server/routes/api/forms.js +765 -746
  29. package/server/routes/api/navigation.js +42 -42
  30. package/server/routes/api/projects.js +9 -2
  31. package/server/routes/api/settings.js +141 -141
  32. package/server/routes/public.js +202 -202
  33. package/server/server.js +10 -1
  34. package/server/services/apiTokens.js +259 -0
  35. package/server/services/email.js +167 -167
  36. package/server/services/forms.js +345 -255
  37. package/server/services/permissionRegistry.js +13 -0
  38. package/server/services/presetCollections.js +27 -1
  39. package/server/services/projects.js +115 -24
  40. package/server/services/roles.js +16 -0
  41. package/server/services/scaffolder.js +31 -1
  42. package/server/services/sidebar-migration.js +44 -0
  43. package/server/services/userProfiles.js +199 -199
  44. package/server/services/users.js +302 -302
  45. package/config/connections.json.bak +0 -9
@@ -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
+ }
@@ -5,9 +5,9 @@
5
5
  * GET /api/projects/:slug — full project
6
6
  * POST /api/projects — create
7
7
  * PUT /api/projects/:slug — edit (slug immutable)
8
- * DELETE /api/projects/:slug — delete (409 if any artefacts tagged)
8
+ * DELETE /api/projects/:slug — delete (403 for core; 409 if any artefacts tagged)
9
9
  * GET /api/projects/:slug/artefacts — grouped artefacts
10
- * POST /api/projects/:slug/untag-all — escape hatch before delete
10
+ * POST /api/projects/:slug/untag-all — escape hatch before delete (403 for core)
11
11
  *
12
12
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
13
13
  */
@@ -16,6 +16,7 @@ import {
16
16
  requirePermission as defaultRequirePermission
17
17
  } from '../../middleware/auth.js';
18
18
  import {
19
+ CORE_PROJECT_SLUG,
19
20
  createProject,
20
21
  deleteProject,
21
22
  getArtefactsForProject,
@@ -76,6 +77,9 @@ export async function projectsRoutes(fastify, opts = {}) {
76
77
  });
77
78
 
78
79
  fastify.delete('/projects/:slug', canDelete, async (request, reply) => {
80
+ if (request.params.slug === CORE_PROJECT_SLUG) {
81
+ return reply.status(403).send({error: 'Cannot delete the core project'});
82
+ }
79
83
  const grouped = await getArtefactsForProject(request.params.slug);
80
84
  const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
81
85
  if (total > 0) {
@@ -98,6 +102,9 @@ export async function projectsRoutes(fastify, opts = {}) {
98
102
  });
99
103
 
100
104
  fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
105
+ if (request.params.slug === CORE_PROJECT_SLUG) {
106
+ return reply.status(403).send({error: 'Cannot untag the core project — artefacts would immediately resolve back to it'});
107
+ }
101
108
  if (!(await getProject(request.params.slug))) {
102
109
  return reply.status(404).send({error: 'Project not found'});
103
110
  }
@@ -1,141 +1,141 @@
1
- /**
2
- * Settings API
3
- * GET /api/settings - get site settings
4
- * PUT /api/settings - save site settings
5
- * POST /api/settings/test-email - send a test email using stored SMTP config
6
- */
7
- import {getConfig, saveConfig} from '../../config.js';
8
- import {authenticate, requirePermission} from '../../middleware/auth.js';
9
- import nodemailer from 'nodemailer';
10
- import fs from 'fs/promises';
11
- import path from 'path';
12
- import {fileURLToPath} from 'url';
13
- import * as cache from '../../services/cache/index.js';
14
-
15
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
- const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
17
- const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
18
- const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
19
-
20
- export async function settingsRoutes(fastify) {
21
- const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
22
- const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
23
-
24
- fastify.get('/settings', canRead, async (request, reply) => {
25
- const config = getConfig('site');
26
- const safeConfig = { ...config };
27
- if (safeConfig.smtp) {
28
- safeConfig.smtp = { ...safeConfig.smtp, pass: '' };
29
- }
30
- return reply.send(safeConfig);
31
- });
32
-
33
- fastify.put('/settings', canUpdate, async (request, reply) => {
34
- const data = request.body;
35
- if (!data || typeof data !== 'object') {
36
- return reply.status(400).send({ error: 'Invalid settings data' });
37
- }
38
- const ALLOWED_KEYS = new Set([
39
- 'title', 'tagline', 'description', 'logo', 'favicon',
40
- 'theme', 'adminTheme', 'fontFamily', 'fontSize',
41
- 'smtp', 'footer', 'analytics', 'social', 'locale',
42
- 'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
43
- 'adminBrand'
44
- ]);
45
- const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
46
- if (unknownKeys.length > 0) {
47
- return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
48
- }
49
- // Merge incoming data with existing config so partial updates (e.g. just adminBrand)
50
- // don't wipe unrelated keys. Nested objects get a shallow merge so sub-fields are
51
- // preserved even when only some sub-fields are sent.
52
- const existing = getConfig('site');
53
- const merged = {...existing};
54
- for (const [k, v] of Object.entries(data)) {
55
- const isPlainObject = v !== null && typeof v === 'object' && !Array.isArray(v);
56
- const existingIsPlainObject = existing[k] !== null && typeof existing[k] === 'object' && !Array.isArray(existing[k]);
57
- if (isPlainObject && existingIsPlainObject) {
58
- merged[k] = {...existing[k], ...v};
59
- } else {
60
- merged[k] = v;
61
- }
62
- }
63
- // Never erase smtp.pass with the empty string that the GET endpoint injects for safety
64
- if (merged.smtp && merged.smtp.pass === '' && existing.smtp?.pass) {
65
- merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
66
- }
67
- saveConfig('site', merged);
68
- await cache.invalidateTags(['site']);
69
- return { success: true };
70
- });
71
-
72
- fastify.post('/settings/test-email', canRead, async (request, reply) => {
73
- const smtp = getConfig('site')?.smtp;
74
- if (!smtp?.host) {
75
- return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
76
- }
77
-
78
- const transporter = nodemailer.createTransport({
79
- host: smtp.host,
80
- port: smtp.port || 587,
81
- secure: smtp.secure || false,
82
- auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
83
- });
84
-
85
- const to = request.body?.to || smtp.fromAddress;
86
- if (!to) {
87
- return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
88
- }
89
-
90
- try {
91
- await transporter.sendMail({
92
- from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
93
- to,
94
- subject: 'Domma CMS — Test Email',
95
- text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
96
- html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
97
- });
98
- return { success: true, message: `Test email sent to ${to}` };
99
- } catch (err) {
100
- return reply.status(500).send({ error: `Failed to send email: ${err.message}` });
101
- }
102
- });
103
-
104
- // GET /api/settings/db-status — returns whether MongoDB connections are configured
105
- fastify.get('/settings/db-status', canRead, async () => {
106
- try {
107
- const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
108
- const connections = JSON.parse(raw);
109
- const names = Object.keys(connections).filter(k =>
110
- connections[k]?.type === 'mongodb' && connections[k]?.uri
111
- );
112
- return {configured: names.length > 0, connections: names};
113
- } catch {
114
- return {configured: false, connections: []};
115
- }
116
- });
117
-
118
- // GET /api/settings/custom-css — return current CSS as JSON
119
- fastify.get('/settings/custom-css', canUpdate, async () => {
120
- try {
121
- const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
122
- return { css };
123
- } catch {
124
- return { css: '' };
125
- }
126
- });
127
-
128
- // PUT /api/settings/custom-css — save CSS to content/custom.css
129
- fastify.put('/settings/custom-css', canUpdate, async (request, reply) => {
130
- const { css } = request.body || {};
131
- if (typeof css !== 'string') {
132
- return reply.status(400).send({ error: 'css must be a string.' });
133
- }
134
- if (Buffer.byteLength(css, 'utf8') > CUSTOM_CSS_MAX) {
135
- return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
136
- }
137
- await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
138
- await cache.invalidateTags(['site']);
139
- return { success: true };
140
- });
141
- }
1
+ /**
2
+ * Settings API
3
+ * GET /api/settings - get site settings
4
+ * PUT /api/settings - save site settings
5
+ * POST /api/settings/test-email - send a test email using stored SMTP config
6
+ */
7
+ import {getConfig, saveConfig} from '../../config.js';
8
+ import {authenticate, requirePermission} from '../../middleware/auth.js';
9
+ import nodemailer from 'nodemailer';
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+ import {fileURLToPath} from 'url';
13
+ import * as cache from '../../services/cache/index.js';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const CUSTOM_CSS_FILE = path.resolve(__dirname, '../../../content/custom.css');
17
+ const CONNECTIONS_FILE = path.resolve(__dirname, '../../../config/connections.json');
18
+ const CUSTOM_CSS_MAX = 100 * 1024; // 100 KB
19
+
20
+ export async function settingsRoutes(fastify) {
21
+ const canRead = {preHandler: [authenticate, requirePermission('settings', 'read')]};
22
+ const canUpdate = {preHandler: [authenticate, requirePermission('settings', 'update')]};
23
+
24
+ fastify.get('/settings', canRead, async (request, reply) => {
25
+ const config = getConfig('site');
26
+ const safeConfig = { ...config };
27
+ if (safeConfig.smtp) {
28
+ safeConfig.smtp = { ...safeConfig.smtp, pass: '' };
29
+ }
30
+ return reply.send(safeConfig);
31
+ });
32
+
33
+ fastify.put('/settings', canUpdate, async (request, reply) => {
34
+ const data = request.body;
35
+ if (!data || typeof data !== 'object') {
36
+ return reply.status(400).send({ error: 'Invalid settings data' });
37
+ }
38
+ const ALLOWED_KEYS = new Set([
39
+ 'title', 'tagline', 'description', 'logo', 'favicon',
40
+ 'theme', 'adminTheme', 'fontFamily', 'fontSize',
41
+ 'smtp', 'footer', 'analytics', 'social', 'locale',
42
+ 'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
43
+ 'adminBrand'
44
+ ]);
45
+ const unknownKeys = Object.keys(data).filter(k => !ALLOWED_KEYS.has(k));
46
+ if (unknownKeys.length > 0) {
47
+ return reply.status(400).send({ error: `Unknown settings keys: ${unknownKeys.join(', ')}` });
48
+ }
49
+ // Merge incoming data with existing config so partial updates (e.g. just adminBrand)
50
+ // don't wipe unrelated keys. Nested objects get a shallow merge so sub-fields are
51
+ // preserved even when only some sub-fields are sent.
52
+ const existing = getConfig('site');
53
+ const merged = {...existing};
54
+ for (const [k, v] of Object.entries(data)) {
55
+ const isPlainObject = v !== null && typeof v === 'object' && !Array.isArray(v);
56
+ const existingIsPlainObject = existing[k] !== null && typeof existing[k] === 'object' && !Array.isArray(existing[k]);
57
+ if (isPlainObject && existingIsPlainObject) {
58
+ merged[k] = {...existing[k], ...v};
59
+ } else {
60
+ merged[k] = v;
61
+ }
62
+ }
63
+ // Never erase smtp.pass with the empty string that the GET endpoint injects for safety
64
+ if (merged.smtp && merged.smtp.pass === '' && existing.smtp?.pass) {
65
+ merged.smtp = {...merged.smtp, pass: existing.smtp.pass};
66
+ }
67
+ saveConfig('site', merged);
68
+ await cache.invalidateTags(['site']);
69
+ return { success: true };
70
+ });
71
+
72
+ fastify.post('/settings/test-email', canRead, async (request, reply) => {
73
+ const smtp = getConfig('site')?.smtp;
74
+ if (!smtp?.host) {
75
+ return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
76
+ }
77
+
78
+ const transporter = nodemailer.createTransport({
79
+ host: smtp.host,
80
+ port: smtp.port || 587,
81
+ secure: smtp.secure || false,
82
+ auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined
83
+ });
84
+
85
+ const to = request.body?.to || smtp.fromAddress;
86
+ if (!to) {
87
+ return reply.status(400).send({ error: 'No recipient address. Provide "to" in the request body or set a From Address.' });
88
+ }
89
+
90
+ try {
91
+ await transporter.sendMail({
92
+ from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
93
+ to,
94
+ subject: 'Domma CMS — Test Email',
95
+ text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
96
+ html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
97
+ });
98
+ return { success: true, message: `Test email sent to ${to}` };
99
+ } catch (err) {
100
+ return reply.status(500).send({ error: `Failed to send email: ${err.message}` });
101
+ }
102
+ });
103
+
104
+ // GET /api/settings/db-status — returns whether MongoDB connections are configured
105
+ fastify.get('/settings/db-status', canRead, async () => {
106
+ try {
107
+ const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
108
+ const connections = JSON.parse(raw);
109
+ const names = Object.keys(connections).filter(k =>
110
+ connections[k]?.type === 'mongodb' && connections[k]?.uri
111
+ );
112
+ return {configured: names.length > 0, connections: names};
113
+ } catch {
114
+ return {configured: false, connections: []};
115
+ }
116
+ });
117
+
118
+ // GET /api/settings/custom-css — return current CSS as JSON
119
+ fastify.get('/settings/custom-css', canUpdate, async () => {
120
+ try {
121
+ const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
122
+ return { css };
123
+ } catch {
124
+ return { css: '' };
125
+ }
126
+ });
127
+
128
+ // PUT /api/settings/custom-css — save CSS to content/custom.css
129
+ fastify.put('/settings/custom-css', canUpdate, async (request, reply) => {
130
+ const { css } = request.body || {};
131
+ if (typeof css !== 'string') {
132
+ return reply.status(400).send({ error: 'css must be a string.' });
133
+ }
134
+ if (Buffer.byteLength(css, 'utf8') > CUSTOM_CSS_MAX) {
135
+ return reply.status(400).send({ error: 'CSS exceeds the 100 KB limit.' });
136
+ }
137
+ await fs.writeFile(CUSTOM_CSS_FILE, css, 'utf8');
138
+ await cache.invalidateTags(['site']);
139
+ return { success: true };
140
+ });
141
+ }