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
@@ -0,0 +1,259 @@
1
+ /**
2
+ * API Tokens
3
+ * Project-scoped machine credentials for the external collections API.
4
+ *
5
+ * Tokens are entries in the file-based `api-tokens` preset collection.
6
+ * Only a SHA-256 hash is stored — the plaintext (`dcms_<64 hex>`) is returned
7
+ * exactly once, from createToken(). A token is accepted only on collection
8
+ * verbs configured with `api.<verb>.access === 'token'`, and only for
9
+ * collections whose resolved project matches the token's `project` binding.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from './collections.js';
13
+ import {canSeeArtefact, getProject} from './projects.js';
14
+ import {hooks} from './hooks.js';
15
+
16
+ export const API_TOKENS_COLLECTION_SLUG = 'api-tokens';
17
+
18
+ const TOKEN_RE = /^dcms_[a-f0-9]{64}$/;
19
+ const VERBS = ['create', 'read', 'update', 'delete'];
20
+ const LAST_USED_THROTTLE_MS = 60_000;
21
+
22
+ /** In-memory hash → entry cache; null = needs rebuild. */
23
+ let tokenCache = null;
24
+
25
+ function invalidateCache() {
26
+ tokenCache = null;
27
+ }
28
+
29
+ // Generic admin collection endpoints emit these for ALL collections — the
30
+ // service's own mutations invalidate directly, this catches edits made
31
+ // through the admin entries grid.
32
+ for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
33
+ hooks.on(ev, (payload) => {
34
+ if (payload?.slug === API_TOKENS_COLLECTION_SLUG) invalidateCache();
35
+ });
36
+ }
37
+
38
+ /**
39
+ * SHA-256 hex digest of a plaintext token.
40
+ *
41
+ * @param {string} plaintext
42
+ * @returns {string}
43
+ */
44
+ function hashToken(plaintext) {
45
+ return crypto.createHash('sha256').update(plaintext).digest('hex');
46
+ }
47
+
48
+ /**
49
+ * Strip the entry down to what callers may see. Never includes tokenHash.
50
+ *
51
+ * @param {object} entry - Raw collection entry { id, data, meta }
52
+ * @returns {object}
53
+ */
54
+ function sanitise(entry) {
55
+ const {name, project, tokenHint, scopes, enabled, expiresAt, lastUsedAt, createdBy} = entry.data || {};
56
+ return {
57
+ id: entry.id,
58
+ name, project, tokenHint,
59
+ scopes: Array.isArray(scopes) ? scopes : [],
60
+ enabled: enabled !== false,
61
+ expiresAt: expiresAt || null,
62
+ lastUsedAt: lastUsedAt || null,
63
+ createdBy: createdBy || null,
64
+ meta: entry.meta
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Validate a scopes declaration: array of { collection, verbs[] }.
70
+ * Empty array = the token covers every collection in its project.
71
+ *
72
+ * @param {*} scopes
73
+ * @returns {string|null} Error message, or null when valid
74
+ */
75
+ function validateScopes(scopes) {
76
+ if (scopes == null) return null;
77
+ if (!Array.isArray(scopes)) return 'scopes must be an array';
78
+ for (const s of scopes) {
79
+ if (!s || typeof s !== 'object' || typeof s.collection !== 'string' || !s.collection.trim()) {
80
+ return 'each scope needs a collection slug';
81
+ }
82
+ if (s.verbs != null) {
83
+ if (!Array.isArray(s.verbs)) return 'scope verbs must be an array';
84
+ const bad = s.verbs.find(v => !VERBS.includes(v));
85
+ if (bad) return `unknown scope verb "${bad}"`;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Check whether a token's scopes permit an operation on a collection.
93
+ * Empty/missing scopes → everything in the token's project is allowed.
94
+ *
95
+ * @param {Array<{collection: string, verbs?: string[]}>} scopes
96
+ * @param {string} collectionSlug
97
+ * @param {string} verb - create | read | update | delete
98
+ * @returns {boolean}
99
+ */
100
+ export function scopeAllows(scopes, collectionSlug, verb) {
101
+ if (!Array.isArray(scopes) || scopes.length === 0) return true;
102
+ const scope = scopes.find(s => s.collection === collectionSlug);
103
+ if (!scope) return false;
104
+ return !Array.isArray(scope.verbs) || scope.verbs.length === 0 || scope.verbs.includes(verb);
105
+ }
106
+
107
+ /**
108
+ * Create a new token. The plaintext is returned ONCE here and never again.
109
+ *
110
+ * @param {{name: string, project: string, scopes?: object[], expiresAt?: string|null, createdBy?: string|null}} input
111
+ * @returns {Promise<{entry: object, plaintext: string}>} Sanitised entry + plaintext
112
+ * @throws {Error} On validation failure
113
+ */
114
+ export async function createToken({name, project, scopes = [], expiresAt = null, createdBy = null}) {
115
+ if (!name || typeof name !== 'string' || !name.trim()) throw new Error('Token name is required');
116
+ if (!project || typeof project !== 'string') throw new Error('Token project is required');
117
+ if (!await getProject(project)) throw new Error(`Unknown project "${project}"`);
118
+ const scopeError = validateScopes(scopes);
119
+ if (scopeError) throw new Error(scopeError);
120
+ if (expiresAt != null && Number.isNaN(Date.parse(expiresAt))) throw new Error('expiresAt must be a valid date');
121
+
122
+ const plaintext = 'dcms_' + crypto.randomBytes(32).toString('hex');
123
+ const data = {
124
+ name: name.trim(),
125
+ project,
126
+ tokenHash: hashToken(plaintext),
127
+ tokenHint: plaintext.slice(-4),
128
+ scopes: scopes || [],
129
+ enabled: true,
130
+ expiresAt: expiresAt || null,
131
+ lastUsedAt: null,
132
+ createdBy
133
+ };
134
+
135
+ const entry = await createEntry(API_TOKENS_COLLECTION_SLUG, data, {createdBy, source: 'admin'});
136
+ invalidateCache();
137
+ return {entry: sanitise(entry), plaintext};
138
+ }
139
+
140
+ /**
141
+ * Look up a token by name + project. Used for scaffolder idempotency.
142
+ *
143
+ * @param {string} name
144
+ * @param {string} project
145
+ * @returns {Promise<object|null>} Sanitised entry or null
146
+ */
147
+ export async function findTokenByName(name, project) {
148
+ const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
149
+ const entry = entries.find(e => e.data?.name === name && e.data?.project === project);
150
+ return entry ? sanitise(entry) : null;
151
+ }
152
+
153
+ /**
154
+ * Validate a presented plaintext token.
155
+ * Returns the sanitised entry when the token exists, is enabled, and has not
156
+ * expired — otherwise null. Updates lastUsedAt at most once per minute
157
+ * (fire-and-forget; the cached copy is patched in place to avoid thrash).
158
+ *
159
+ * @param {string} plaintext
160
+ * @returns {Promise<object|null>}
161
+ */
162
+ export async function validateToken(plaintext) {
163
+ if (typeof plaintext !== 'string' || !TOKEN_RE.test(plaintext)) return null;
164
+ const hash = hashToken(plaintext);
165
+
166
+ if (!tokenCache) {
167
+ const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
168
+ tokenCache = new Map(entries.map(e => [e.data?.tokenHash, e]));
169
+ }
170
+
171
+ const entry = tokenCache.get(hash);
172
+ if (!entry) return null;
173
+ if (entry.data.enabled === false) return null;
174
+ if (entry.data.expiresAt && Date.parse(entry.data.expiresAt) < Date.now()) return null;
175
+
176
+ const lastUsed = entry.data.lastUsedAt ? Date.parse(entry.data.lastUsedAt) : 0;
177
+ if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
178
+ // updateEntry replaces data wholesale — pass the full object.
179
+ const data = {...entry.data, lastUsedAt: new Date().toISOString()};
180
+ entry.data = data; // keep the cached copy current without invalidating
181
+ updateEntry(API_TOKENS_COLLECTION_SLUG, entry.id, data).catch(() => {});
182
+ }
183
+
184
+ return sanitise(entry);
185
+ }
186
+
187
+ /**
188
+ * List tokens the given admin user may see (project access scope applies).
189
+ *
190
+ * @param {object} user
191
+ * @returns {Promise<object[]>} Sanitised entries
192
+ */
193
+ export async function listTokensSanitised(user) {
194
+ const {entries} = await listEntries(API_TOKENS_COLLECTION_SLUG, {limit: 0});
195
+ return entries
196
+ .filter(e => canSeeArtefact(user, {meta: {project: e.data?.project}}))
197
+ .map(sanitise);
198
+ }
199
+
200
+ /**
201
+ * Fetch a single token, sanitised. Caller is responsible for canSeeArtefact.
202
+ *
203
+ * @param {string} id
204
+ * @returns {Promise<object|null>}
205
+ */
206
+ export async function getTokenSanitised(id) {
207
+ const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
208
+ return entry ? sanitise(entry) : null;
209
+ }
210
+
211
+ /**
212
+ * Update mutable token fields. The project binding, hash, and hint are fixed
213
+ * for the token's lifetime — revoke and re-issue to rebind.
214
+ *
215
+ * @param {string} id
216
+ * @param {{name?: string, enabled?: boolean, scopes?: object[], expiresAt?: string|null}} patch
217
+ * @returns {Promise<object|null>} Sanitised entry, or null when not found
218
+ * @throws {Error} On validation failure
219
+ */
220
+ export async function updateToken(id, patch = {}) {
221
+ const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
222
+ if (!entry) return null;
223
+
224
+ const data = {...entry.data};
225
+ if (patch.name != null) {
226
+ if (typeof patch.name !== 'string' || !patch.name.trim()) throw new Error('Token name is required');
227
+ data.name = patch.name.trim();
228
+ }
229
+ if (patch.enabled != null) data.enabled = patch.enabled !== false;
230
+ if (patch.scopes != null) {
231
+ const scopeError = validateScopes(patch.scopes);
232
+ if (scopeError) throw new Error(scopeError);
233
+ data.scopes = patch.scopes;
234
+ }
235
+ if (patch.expiresAt !== undefined) {
236
+ if (patch.expiresAt != null && Number.isNaN(Date.parse(patch.expiresAt))) {
237
+ throw new Error('expiresAt must be a valid date');
238
+ }
239
+ data.expiresAt = patch.expiresAt || null;
240
+ }
241
+
242
+ const updated = await updateEntry(API_TOKENS_COLLECTION_SLUG, id, data);
243
+ invalidateCache();
244
+ return updated ? sanitise(updated) : null;
245
+ }
246
+
247
+ /**
248
+ * Revoke (delete) a token. Takes effect immediately.
249
+ *
250
+ * @param {string} id
251
+ * @returns {Promise<boolean>}
252
+ */
253
+ export async function revokeToken(id) {
254
+ const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
255
+ if (!entry) return false;
256
+ await deleteEntry(API_TOKENS_COLLECTION_SLUG, id);
257
+ invalidateCache();
258
+ return true;
259
+ }
@@ -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, '&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
- }
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
+ }
@@ -202,6 +202,19 @@ export const REGISTRY = [
202
202
  {key: 'update', label: 'Dismiss', description: 'Mark notifications as read'},
203
203
  {key: 'delete', label: 'Clear', description: 'Delete notifications'}
204
204
  ]
205
+ },
206
+ {
207
+ key: 'api-tokens',
208
+ label: 'API Tokens',
209
+ description: 'Manage project-scoped tokens for the external collections API.',
210
+ icon: 'key',
211
+ group: 'Configuration',
212
+ actions: [
213
+ {key: 'read', label: 'View', description: 'View API tokens (hashes are never shown)'},
214
+ {key: 'create', label: 'Create', description: 'Create new API tokens'},
215
+ {key: 'update', label: 'Edit', description: 'Rename, enable/disable, or edit token scopes'},
216
+ {key: 'delete', label: 'Revoke', description: 'Revoke (delete) API tokens'}
217
+ ]
205
218
  }
206
219
  ];
207
220
 
@@ -60,6 +60,31 @@ const PRESETS = [
60
60
  delete: {enabled: false, access: 'admin'}
61
61
  }
62
62
  }
63
+ ,
64
+ {
65
+ slug: 'api-tokens',
66
+ title: 'API Tokens',
67
+ description: 'Project-scoped tokens for the external collections API.',
68
+ preset: true,
69
+ systemManaged: true,
70
+ fields: [
71
+ {name: 'name', label: 'Name', type: 'text', required: true},
72
+ {name: 'project', label: 'Project', type: 'text', required: true},
73
+ {name: 'tokenHash', label: 'Token Hash', type: 'hidden', required: true},
74
+ {name: 'tokenHint', label: 'Hint', type: 'text'},
75
+ {name: 'scopes', label: 'Scopes', type: 'array', items: 'object', default: []},
76
+ {name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
77
+ {name: 'expiresAt', label: 'Expires At', type: 'datetime'},
78
+ {name: 'lastUsedAt', label: 'Last Used', type: 'datetime'},
79
+ {name: 'createdBy', label: 'Created By', type: 'text'}
80
+ ],
81
+ api: {
82
+ create: {enabled: false, access: 'admin'},
83
+ read: {enabled: false, access: 'admin'},
84
+ update: {enabled: false, access: 'admin'},
85
+ delete: {enabled: false, access: 'admin'}
86
+ }
87
+ }
63
88
  ];
64
89
 
65
90
  /** Slugs exported for use in adapterRegistry and the delete guard. */
@@ -198,6 +198,22 @@ export async function seed() {
198
198
  await writeData(entries);
199
199
  }
200
200
 
201
+ // Self-heal: ensure the level-0 root role always carries every registry
202
+ // resource. Role permissions are a persisted snapshot taken at seed time,
203
+ // so new permission families added in an update (e.g. api-tokens) would
204
+ // otherwise be invisible — even to the super-admin — on existing installs.
205
+ // Other roles are intentionally NOT back-filled; admins grant new
206
+ // families via the role editor.
207
+ const root = entries.find(e => e.data?.level === 0);
208
+ if (root) {
209
+ const perms = root.data.permissions || [];
210
+ const missing = RESOURCES.filter(r => !perms.some(p => p === r || p.startsWith(`${r}.`)));
211
+ if (missing.length) {
212
+ root.data.permissions = [...perms, ...missing];
213
+ await writeData(entries);
214
+ }
215
+ }
216
+
201
217
  // Migrate existing user files whose role is no longer recognised
202
218
  await migrateUserRoles(entries);
203
219
  }