domma-cms 0.23.0 → 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 (38) hide show
  1. package/CLAUDE.md +9 -0
  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/role-editor.html +70 -70
  12. package/admin/js/templates/roles.html +10 -10
  13. package/admin/js/views/api-tokens.js +8 -0
  14. package/admin/js/views/collection-editor.js +4 -4
  15. package/admin/js/views/index.js +1 -1
  16. package/admin/js/views/roles.js +1 -1
  17. package/bin/lib/config-merge.js +44 -44
  18. package/bin/update.js +547 -547
  19. package/config/menus/admin-sidebar.json +7 -1
  20. package/package.json +1 -1
  21. package/server/middleware/auth.js +253 -253
  22. package/server/routes/api/api-tokens.js +83 -0
  23. package/server/routes/api/auth.js +309 -309
  24. package/server/routes/api/collections.js +113 -16
  25. package/server/routes/api/navigation.js +42 -42
  26. package/server/routes/api/settings.js +141 -141
  27. package/server/routes/public.js +202 -202
  28. package/server/server.js +8 -1
  29. package/server/services/apiTokens.js +259 -0
  30. package/server/services/email.js +167 -167
  31. package/server/services/permissionRegistry.js +13 -0
  32. package/server/services/presetCollections.js +25 -0
  33. package/server/services/roles.js +16 -0
  34. package/server/services/scaffolder.js +31 -1
  35. package/server/services/sidebar-migration.js +44 -0
  36. package/server/services/userProfiles.js +199 -199
  37. package/server/services/users.js +302 -302
  38. package/config/connections.json.bak +0 -9
@@ -279,7 +279,7 @@ export async function applyRecipe(recipeSlug, opts = {}) {
279
279
  throw err;
280
280
  }
281
281
 
282
- const created = { collection: null, form: null, actions: [], roles: [], users: [], menus: [] };
282
+ const created = { collection: null, form: null, actions: [], roles: [], users: [], menus: [], apiTokens: [] };
283
283
  const skipped = [];
284
284
  const warnings = [];
285
285
 
@@ -459,6 +459,36 @@ export async function applyRecipe(recipeSlug, opts = {}) {
459
459
  } catch { /* non-fatal — admin can wire it manually */ }
460
460
  }
461
461
 
462
+ // API tokens — generated at apply time; the plaintext rides ONCE in
463
+ // `created.apiTokens[].token` (only a hash is stored). Idempotent:
464
+ // re-applying never re-issues an existing name+project token, so the
465
+ // credential the user already deployed survives recipe re-runs.
466
+ const tokenDecls = resolved.apiTokens ? [].concat(resolved.apiTokens) : [];
467
+ if (tokenDecls.length) {
468
+ const {createToken, findTokenByName} = await import('./apiTokens.js');
469
+ const tokenProject = projectSlug || tokens.namespace || 'core';
470
+ for (const t of tokenDecls) {
471
+ if (!t.name) continue;
472
+ if (await findTokenByName(t.name, tokenProject)) {
473
+ skipped.push(`apiToken:${t.name}`);
474
+ warnings.push(`API token "${t.name}" already exists for project "${tokenProject}" — left unchanged (token value NOT re-issued)`);
475
+ continue;
476
+ }
477
+ try {
478
+ const {plaintext} = await createToken({
479
+ name: t.name,
480
+ project: tokenProject,
481
+ scopes: Array.isArray(t.scopes) ? t.scopes : [],
482
+ expiresAt: t.expiresAt || null,
483
+ createdBy: opts.createdBy || null
484
+ });
485
+ created.apiTokens.push({name: t.name, token: plaintext});
486
+ } catch (err) {
487
+ warnings.push(`API token "${t.name}" failed: ${err.message}`);
488
+ }
489
+ }
490
+ }
491
+
462
492
  const snippet = resolved.snippet || null;
463
493
 
464
494
  return { created, skipped, warnings, snippet };
@@ -55,6 +55,7 @@ const SEED_ITEMS = [
55
55
  {text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
56
56
  {text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
57
57
  {text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
58
+ {text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'},
58
59
  {text: 'My Profile', url: '#/my-profile', icon: 'user'}
59
60
  ]
60
61
  },
@@ -115,3 +116,46 @@ export async function runMigration(opts = {}) {
115
116
  console.log('[admin-sidebar] Seeded admin-sidebar menu + mapped admin-sidebar slot');
116
117
  return {migrated: true};
117
118
  }
119
+
120
+ /**
121
+ * Append-if-absent: ensure a single item exists in the persisted admin
122
+ * sidebar menu. Lets updates surface new admin pages on EXISTING installs
123
+ * (the seed above only reaches fresh ones) without clobbering admin edits:
124
+ * if any node in the tree already has the item's url — even moved, renamed,
125
+ * or hidden — this is a no-op.
126
+ *
127
+ * Returns `{ensured: boolean, reason?: string}`.
128
+ *
129
+ * @param {{groupText: string, item: {text: string, url: string, icon?: string, permission?: string}}} spec
130
+ * @param {{configDir?: string}} [opts]
131
+ */
132
+ export async function ensureSidebarItem({groupText, item}, opts = {}) {
133
+ const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
134
+ const menuPath = path.join(configDir, 'menus', 'admin-sidebar.json');
135
+
136
+ if (!await exists(menuPath)) {
137
+ return {ensured: false, reason: 'admin-sidebar.json not present'};
138
+ }
139
+
140
+ const menu = await readJson(menuPath);
141
+ const items = Array.isArray(menu.items) ? menu.items : (menu.items = []);
142
+
143
+ const hasUrl = (nodes) => nodes.some(n => n?.url === item.url || (Array.isArray(n?.items) && hasUrl(n.items)));
144
+ if (hasUrl(items)) {
145
+ return {ensured: false, reason: 'item already present'};
146
+ }
147
+
148
+ let group = items.find(n => typeof n?.text === 'string' && n.text.toLowerCase() === groupText.toLowerCase());
149
+ if (!group) {
150
+ group = {text: groupText, items: []};
151
+ items.push(group);
152
+ }
153
+ if (!Array.isArray(group.items)) group.items = [];
154
+ group.items.push(item);
155
+
156
+ menu.meta = {...(menu.meta || {}), updatedAt: new Date().toISOString()};
157
+ await writeJson(menuPath, menu);
158
+
159
+ console.log(`[admin-sidebar] Added "${item.text}" to the ${groupText} group`);
160
+ return {ensured: true};
161
+ }
@@ -1,199 +1,199 @@
1
- /**
2
- * User Profiles Service
3
- * Preset Collection — stores extended profile data for each user,
4
- * keyed by user UUID. Seeded on startup; entries auto-managed on user lifecycle.
5
- */
6
- import fs from 'fs/promises';
7
- import path from 'path';
8
- import {config} from '../config.js';
9
-
10
- const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
11
- const SLUG = 'user-profiles';
12
- const DIR = path.join(COLLECTIONS_DIR, SLUG);
13
- const SCHEMA_PATH = path.join(DIR, 'schema.json');
14
- const DATA_PATH = path.join(DIR, 'data.json');
15
-
16
- const USERS_DIR = path.resolve(config.content.usersDir);
17
-
18
- const PRESET_SCHEMA = {
19
- slug: SLUG,
20
- title: 'User Profiles',
21
- description: 'Extended profile data linked to users by ID.',
22
- preset: true,
23
- fields: [],
24
- api: {
25
- create: {enabled: false, access: 'admin'},
26
- read: {enabled: false, access: 'admin'},
27
- update: {enabled: false, access: 'admin'},
28
- delete: {enabled: false, access: 'admin'}
29
- }
30
- };
31
-
32
- // ---------------------------------------------------------------------------
33
- // Helpers
34
- // ---------------------------------------------------------------------------
35
-
36
- /**
37
- * Read data.json, returning an empty array on any error.
38
- *
39
- * @returns {Promise<object[]>}
40
- */
41
- async function readData() {
42
- try {
43
- const raw = await fs.readFile(DATA_PATH, 'utf8');
44
- return JSON.parse(raw);
45
- } catch {
46
- return [];
47
- }
48
- }
49
-
50
- /**
51
- * Write entries array to data.json.
52
- *
53
- * @param {object[]} entries
54
- * @returns {Promise<void>}
55
- */
56
- async function writeData(entries) {
57
- await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
58
- }
59
-
60
- // ---------------------------------------------------------------------------
61
- // Public API
62
- // ---------------------------------------------------------------------------
63
-
64
- /**
65
- * Seed the preset collection on boot.
66
- * Schema is always overwritten; data.json is only created if absent.
67
- *
68
- * @returns {Promise<void>}
69
- */
70
- export async function seed() {
71
- await fs.mkdir(DIR, {recursive: true});
72
-
73
- // Only write schema if absent — admin edits to fields must be preserved across restarts
74
- try {
75
- await fs.access(SCHEMA_PATH);
76
- } catch {
77
- await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
78
- }
79
-
80
- // Only create data file if absent
81
- try {
82
- await fs.access(DATA_PATH);
83
- } catch {
84
- await writeData([]);
85
- }
86
- }
87
-
88
- /**
89
- * Ensure a profile entry exists for the given user ID.
90
- * Creates an empty profile if one does not already exist.
91
- *
92
- * @param {string} userId
93
- * @returns {Promise<void>}
94
- */
95
- export async function ensureProfile(userId) {
96
- const entries = await readData();
97
- const exists = entries.some(e => e.id === userId);
98
- if (!exists) {
99
- entries.push({
100
- id: userId,
101
- data: {},
102
- meta: {createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()}
103
- });
104
- await writeData(entries);
105
- }
106
- }
107
-
108
- /**
109
- * Get the profile entry for a user, or null if not found.
110
- *
111
- * @param {string} userId
112
- * @returns {Promise<object|null>}
113
- */
114
- export async function getProfile(userId) {
115
- const entries = await readData();
116
- return entries.find(e => e.id === userId) || null;
117
- }
118
-
119
- /**
120
- * Merge new data into a user's profile.
121
- * Creates the profile entry if it does not exist.
122
- *
123
- * @param {string} userId
124
- * @param {object} data - Flat key/value pairs to merge into profile data
125
- * @returns {Promise<object>} Updated profile entry
126
- */
127
- export async function updateProfile(userId, data) {
128
- const entries = await readData();
129
- const index = entries.findIndex(e => e.id === userId);
130
-
131
- if (index === -1) {
132
- const entry = {
133
- id: userId,
134
- data: {...data},
135
- meta: {createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()}
136
- };
137
- entries.push(entry);
138
- await writeData(entries);
139
- return entry;
140
- }
141
-
142
- entries[index] = {
143
- ...entries[index],
144
- data: {...entries[index].data, ...data},
145
- meta: {...entries[index].meta, updatedAt: new Date().toISOString()}
146
- };
147
- await writeData(entries);
148
- return entries[index];
149
- }
150
-
151
- /**
152
- * Remove a user's profile entry. Silent if not found.
153
- *
154
- * @param {string} userId
155
- * @returns {Promise<void>}
156
- */
157
- export async function deleteProfile(userId) {
158
- const entries = await readData();
159
- const filtered = entries.filter(e => e.id !== userId);
160
- if (filtered.length !== entries.length) {
161
- await writeData(filtered);
162
- }
163
- }
164
-
165
- /**
166
- * Return a Map of userId → profile data object.
167
- * Reads data.json once — use this for list endpoints to avoid N+1 reads.
168
- *
169
- * @returns {Promise<Map<string, object>>}
170
- */
171
- export async function listProfiles() {
172
- const entries = await readData();
173
- const map = new Map();
174
- for (const entry of entries) {
175
- map.set(entry.id, entry.data || {});
176
- }
177
- return map;
178
- }
179
-
180
- /**
181
- * Migration helper — ensure every existing user has a profile entry.
182
- * Called once on startup after seed().
183
- *
184
- * @returns {Promise<void>}
185
- */
186
- export async function ensureAllProfiles() {
187
- let userFiles;
188
- try {
189
- userFiles = await fs.readdir(USERS_DIR);
190
- } catch {
191
- return;
192
- }
193
-
194
- const jsonFiles = userFiles.filter(f => f.endsWith('.json'));
195
- for (const file of jsonFiles) {
196
- const userId = file.replace(/\.json$/, '');
197
- await ensureProfile(userId);
198
- }
199
- }
1
+ /**
2
+ * User Profiles Service
3
+ * Preset Collection — stores extended profile data for each user,
4
+ * keyed by user UUID. Seeded on startup; entries auto-managed on user lifecycle.
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {config} from '../config.js';
9
+
10
+ const COLLECTIONS_DIR = path.resolve(config.content.collectionsDir);
11
+ const SLUG = 'user-profiles';
12
+ const DIR = path.join(COLLECTIONS_DIR, SLUG);
13
+ const SCHEMA_PATH = path.join(DIR, 'schema.json');
14
+ const DATA_PATH = path.join(DIR, 'data.json');
15
+
16
+ const USERS_DIR = path.resolve(config.content.usersDir);
17
+
18
+ const PRESET_SCHEMA = {
19
+ slug: SLUG,
20
+ title: 'User Profiles',
21
+ description: 'Extended profile data linked to users by ID.',
22
+ preset: true,
23
+ fields: [],
24
+ api: {
25
+ create: {enabled: false, access: 'admin'},
26
+ read: {enabled: false, access: 'admin'},
27
+ update: {enabled: false, access: 'admin'},
28
+ delete: {enabled: false, access: 'admin'}
29
+ }
30
+ };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Helpers
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Read data.json, returning an empty array on any error.
38
+ *
39
+ * @returns {Promise<object[]>}
40
+ */
41
+ async function readData() {
42
+ try {
43
+ const raw = await fs.readFile(DATA_PATH, 'utf8');
44
+ return JSON.parse(raw);
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Write entries array to data.json.
52
+ *
53
+ * @param {object[]} entries
54
+ * @returns {Promise<void>}
55
+ */
56
+ async function writeData(entries) {
57
+ await fs.writeFile(DATA_PATH, JSON.stringify(entries, null, 2) + '\n', 'utf8');
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Public API
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Seed the preset collection on boot.
66
+ * Schema is always overwritten; data.json is only created if absent.
67
+ *
68
+ * @returns {Promise<void>}
69
+ */
70
+ export async function seed() {
71
+ await fs.mkdir(DIR, {recursive: true});
72
+
73
+ // Only write schema if absent — admin edits to fields must be preserved across restarts
74
+ try {
75
+ await fs.access(SCHEMA_PATH);
76
+ } catch {
77
+ await fs.writeFile(SCHEMA_PATH, JSON.stringify(PRESET_SCHEMA, null, 2) + '\n', 'utf8');
78
+ }
79
+
80
+ // Only create data file if absent
81
+ try {
82
+ await fs.access(DATA_PATH);
83
+ } catch {
84
+ await writeData([]);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Ensure a profile entry exists for the given user ID.
90
+ * Creates an empty profile if one does not already exist.
91
+ *
92
+ * @param {string} userId
93
+ * @returns {Promise<void>}
94
+ */
95
+ export async function ensureProfile(userId) {
96
+ const entries = await readData();
97
+ const exists = entries.some(e => e.id === userId);
98
+ if (!exists) {
99
+ entries.push({
100
+ id: userId,
101
+ data: {},
102
+ meta: {createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()}
103
+ });
104
+ await writeData(entries);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get the profile entry for a user, or null if not found.
110
+ *
111
+ * @param {string} userId
112
+ * @returns {Promise<object|null>}
113
+ */
114
+ export async function getProfile(userId) {
115
+ const entries = await readData();
116
+ return entries.find(e => e.id === userId) || null;
117
+ }
118
+
119
+ /**
120
+ * Merge new data into a user's profile.
121
+ * Creates the profile entry if it does not exist.
122
+ *
123
+ * @param {string} userId
124
+ * @param {object} data - Flat key/value pairs to merge into profile data
125
+ * @returns {Promise<object>} Updated profile entry
126
+ */
127
+ export async function updateProfile(userId, data) {
128
+ const entries = await readData();
129
+ const index = entries.findIndex(e => e.id === userId);
130
+
131
+ if (index === -1) {
132
+ const entry = {
133
+ id: userId,
134
+ data: {...data},
135
+ meta: {createdAt: new Date().toISOString(), updatedAt: new Date().toISOString()}
136
+ };
137
+ entries.push(entry);
138
+ await writeData(entries);
139
+ return entry;
140
+ }
141
+
142
+ entries[index] = {
143
+ ...entries[index],
144
+ data: {...entries[index].data, ...data},
145
+ meta: {...entries[index].meta, updatedAt: new Date().toISOString()}
146
+ };
147
+ await writeData(entries);
148
+ return entries[index];
149
+ }
150
+
151
+ /**
152
+ * Remove a user's profile entry. Silent if not found.
153
+ *
154
+ * @param {string} userId
155
+ * @returns {Promise<void>}
156
+ */
157
+ export async function deleteProfile(userId) {
158
+ const entries = await readData();
159
+ const filtered = entries.filter(e => e.id !== userId);
160
+ if (filtered.length !== entries.length) {
161
+ await writeData(filtered);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Return a Map of userId → profile data object.
167
+ * Reads data.json once — use this for list endpoints to avoid N+1 reads.
168
+ *
169
+ * @returns {Promise<Map<string, object>>}
170
+ */
171
+ export async function listProfiles() {
172
+ const entries = await readData();
173
+ const map = new Map();
174
+ for (const entry of entries) {
175
+ map.set(entry.id, entry.data || {});
176
+ }
177
+ return map;
178
+ }
179
+
180
+ /**
181
+ * Migration helper — ensure every existing user has a profile entry.
182
+ * Called once on startup after seed().
183
+ *
184
+ * @returns {Promise<void>}
185
+ */
186
+ export async function ensureAllProfiles() {
187
+ let userFiles;
188
+ try {
189
+ userFiles = await fs.readdir(USERS_DIR);
190
+ } catch {
191
+ return;
192
+ }
193
+
194
+ const jsonFiles = userFiles.filter(f => f.endsWith('.json'));
195
+ for (const file of jsonFiles) {
196
+ const userId = file.replace(/\.json$/, '');
197
+ await ensureProfile(userId);
198
+ }
199
+ }