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,255 +1,345 @@
1
- /**
2
- * Core Forms Service
3
- * CRUD operations for form definitions stored in content/forms/.
4
- * Submissions are stored exclusively in Collections (slug matching form slug).
5
- */
6
- import fs from 'fs/promises';
7
- import path from 'path';
8
- import {fileURLToPath} from 'url';
9
- import * as cache from './cache/index.js';
10
-
11
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
- const ROOT = path.resolve(__dirname, '..', '..');
13
- export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
14
-
15
- /**
16
- * Ensure the forms directory exists.
17
- *
18
- * @returns {Promise<void>}
19
- */
20
- export async function ensureFormsDir() {
21
- await fs.mkdir(FORMS_DIR, { recursive: true });
22
- }
23
-
24
- /**
25
- * Read a single form definition by slug.
26
- *
27
- * @param {string} slug
28
- * @returns {Promise<object>}
29
- * @throws {Error} If the form file does not exist or cannot be parsed.
30
- */
31
- export async function readForm(slug) {
32
- const file = path.join(FORMS_DIR, `${slug}.json`);
33
- return JSON.parse(await fs.readFile(file, 'utf8'));
34
- }
35
-
36
- /**
37
- * Write a form definition to disk.
38
- *
39
- * @param {string} slug
40
- * @param {object} data
41
- * @returns {Promise<void>}
42
- */
43
- export async function writeForm(slug, data) {
44
- await ensureFormsDir();
45
- const file = path.join(FORMS_DIR, `${slug}.json`);
46
- await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
47
- await cache.invalidateTags([`form:${slug}`]);
48
- }
49
-
50
- /**
51
- * List all form definitions.
52
- *
53
- * @returns {Promise<object[]>}
54
- */
55
- export async function listForms() {
56
- let entries;
57
- try {
58
- entries = await fs.readdir(FORMS_DIR);
59
- } catch {
60
- return [];
61
- }
62
- const forms = [];
63
- for (const entry of entries.filter(e => e.endsWith('.json'))) {
64
- try {
65
- const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
66
- forms.push(data);
67
- } catch {
68
- // skip malformed
69
- }
70
- }
71
- return forms;
72
- }
73
-
74
- /**
75
- * Delete a form definition by slug.
76
- *
77
- * @param {string} slug
78
- * @returns {Promise<void>}
79
- * @throws {Error} If the form file does not exist.
80
- */
81
- export async function deleteForm(slug) {
82
- await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
83
- await cache.invalidateTags([`form:${slug}`]);
84
- }
85
-
86
- /**
87
- * Convert a string to a URL-friendly slug.
88
- *
89
- * @param {string} str
90
- * @returns {string}
91
- */
92
- export function slugify(str) {
93
- return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
94
- }
95
-
96
- /**
97
- * Get a single form definition by slug (alias for readForm).
98
- *
99
- * @param {string} slug
100
- * @returns {Promise<object>}
101
- * @throws {Error} If the form file does not exist or cannot be parsed.
102
- */
103
- export async function getForm(slug) {
104
- return readForm(slug);
105
- }
106
-
107
- /**
108
- * Create a new form definition and write it to disk.
109
- *
110
- * @param {object} data - Form definition data
111
- * @param {string} data.title - Form title (required if no slug)
112
- * @param {string} [data.slug] - Explicit slug (derived from title if omitted)
113
- * @param {string} [data.description]
114
- * @param {Array} [data.fields]
115
- * @param {object} [data.settings]
116
- * @param {object} [data.actions]
117
- * @returns {Promise<object>} The created form object
118
- * @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
119
- */
120
- export async function createForm(data) {
121
- const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
122
-
123
- if (!title?.trim() && !rawSlug?.trim()) {
124
- throw new Error('A title or slug is required to create a form.');
125
- }
126
-
127
- const slug = rawSlug ? slugify(rawSlug) : slugify(title);
128
- if (!slug) {
129
- throw new Error('Could not derive a valid slug from the provided title or slug.');
130
- }
131
-
132
- // Throw if a form with this slug already exists
133
- try {
134
- await readForm(slug);
135
- const err = new Error(`Form with slug "${slug}" already exists`);
136
- err.code = 'FORM_ALREADY_EXISTS';
137
- throw err;
138
- } catch (err) {
139
- if (err.code === 'FORM_ALREADY_EXISTS') throw err;
140
- // File not found — safe to proceed
141
- }
142
-
143
- const now = new Date().toISOString();
144
- const trimmedTitle = title ? title.trim() : slug;
145
-
146
- const form = {
147
- slug,
148
- title: trimmedTitle,
149
- description,
150
- ...(plugin ? {plugin} : {}),
151
- fields: Array.isArray(fields) ? fields : [],
152
- settings: {
153
- submitText: 'Submit',
154
- successMessage: 'Thank you for your submission.',
155
- layout: 'stacked',
156
- honeypot: true,
157
- rateLimitPerMinute: 3,
158
- ...settings
159
- },
160
- actions: {
161
- email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
162
- webhook: { enabled: false, url: '', method: 'POST' },
163
- collection: { enabled: true, slug },
164
- ...actions
165
- },
166
- createdAt: now,
167
- updatedAt: now
168
- };
169
-
170
- await writeForm(slug, form);
171
- return form;
172
- }
173
-
174
- /** System collections that should never have an auto-generated public form. */
175
- const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
176
-
177
- /**
178
- * Collection field type → form field type mapping.
179
- *
180
- * @param {string} type
181
- * @returns {string}
182
- */
183
- function toFormFieldType(type) {
184
- const map = {
185
- string: 'string', text: 'string', email: 'email', tel: 'tel',
186
- number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
187
- checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
188
- date: 'date', time: 'time', url: 'url', hidden: 'hidden'
189
- };
190
- return map[type] || 'string';
191
- }
192
-
193
- /**
194
- * Build a form definition object from a collection schema.
195
- *
196
- * @param {object} schema - Collection schema
197
- * @returns {object} Form definition
198
- */
199
- function buildFormFromCollection(schema) {
200
- const now = new Date().toISOString();
201
- const fields = (schema.fields || []).map(f => {
202
- const field = {
203
- name: f.name,
204
- type: toFormFieldType(f.type),
205
- label: f.label || f.name,
206
- required: !!f.required,
207
- placeholder: f.placeholder || '',
208
- helper: f.helper || '',
209
- tooltip: f.tooltip || ''
210
- };
211
- if (f.options) field.options = f.options;
212
- if (f.validation) field.validation = f.validation;
213
- return field;
214
- });
215
-
216
- return {
217
- slug: schema.slug,
218
- title: schema.title,
219
- description: schema.description || '',
220
- fields,
221
- settings: {
222
- submitText: 'Submit',
223
- successMessage: 'Thank you for your submission.',
224
- layout: 'stacked',
225
- honeypot: true,
226
- rateLimitPerMinute: 5
227
- },
228
- actions: {
229
- email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
230
- webhook: {enabled: false, url: '', method: 'POST'},
231
- collection: {enabled: true, slug: schema.slug}
232
- },
233
- createdAt: now,
234
- updatedAt: now
235
- };
236
- }
237
-
238
- /**
239
- * Ensure a form exists for the given collection schema.
240
- * Creates the form only if absent — never overwrites an existing one.
241
- * Skips system collections (roles, user-profiles).
242
- *
243
- * @param {object} schema - Collection schema
244
- * @returns {Promise<void>}
245
- */
246
- export async function ensureFormForCollection(schema) {
247
- if (NO_FORM_SLUGS.has(schema.slug)) return;
248
- await ensureFormsDir();
249
- const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
250
- try {
251
- await fs.access(filePath);
252
- } catch {
253
- await writeForm(schema.slug, buildFormFromCollection(schema));
254
- }
255
- }
1
+ /**
2
+ * Core Forms Service
3
+ * CRUD operations for form definitions stored in content/forms/.
4
+ * Submissions are stored exclusively in Collections (slug matching form slug).
5
+ */
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import {fileURLToPath} from 'url';
9
+ import * as cache from './cache/index.js';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const ROOT = path.resolve(__dirname, '..', '..');
13
+ export const FORMS_DIR = path.join(ROOT, 'content', 'forms');
14
+
15
+ /**
16
+ * Ensure the forms directory exists.
17
+ *
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function ensureFormsDir() {
21
+ await fs.mkdir(FORMS_DIR, { recursive: true });
22
+ }
23
+
24
+ /**
25
+ * Read a single form definition by slug.
26
+ *
27
+ * @param {string} slug
28
+ * @returns {Promise<object>}
29
+ * @throws {Error} If the form file does not exist or cannot be parsed.
30
+ */
31
+ export async function readForm(slug) {
32
+ const file = path.join(FORMS_DIR, `${slug}.json`);
33
+ return JSON.parse(await fs.readFile(file, 'utf8'));
34
+ }
35
+
36
+ /**
37
+ * Write a form definition to disk.
38
+ *
39
+ * @param {string} slug
40
+ * @param {object} data
41
+ * @returns {Promise<void>}
42
+ */
43
+ export async function writeForm(slug, data) {
44
+ await ensureFormsDir();
45
+ const file = path.join(FORMS_DIR, `${slug}.json`);
46
+ await fs.writeFile(file, JSON.stringify(data, null, 4) + '\n', 'utf8');
47
+ await cache.invalidateTags([`form:${slug}`]);
48
+ }
49
+
50
+ /**
51
+ * List all form definitions.
52
+ *
53
+ * @returns {Promise<object[]>}
54
+ */
55
+ export async function listForms() {
56
+ let entries;
57
+ try {
58
+ entries = await fs.readdir(FORMS_DIR);
59
+ } catch {
60
+ return [];
61
+ }
62
+ const forms = [];
63
+ for (const entry of entries.filter(e => e.endsWith('.json'))) {
64
+ try {
65
+ const data = JSON.parse(await fs.readFile(path.join(FORMS_DIR, entry), 'utf8'));
66
+ forms.push(data);
67
+ } catch {
68
+ // skip malformed
69
+ }
70
+ }
71
+ return forms;
72
+ }
73
+
74
+ /**
75
+ * Delete a form definition by slug.
76
+ *
77
+ * @param {string} slug
78
+ * @returns {Promise<void>}
79
+ * @throws {Error} If the form file does not exist.
80
+ */
81
+ export async function deleteForm(slug) {
82
+ await fs.unlink(path.join(FORMS_DIR, `${slug}.json`));
83
+ await cache.invalidateTags([`form:${slug}`]);
84
+ }
85
+
86
+ /**
87
+ * Convert a string to a URL-friendly slug.
88
+ *
89
+ * @param {string} str
90
+ * @returns {string}
91
+ */
92
+ export function slugify(str) {
93
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
94
+ }
95
+
96
+ /**
97
+ * Get a single form definition by slug (alias for readForm).
98
+ *
99
+ * @param {string} slug
100
+ * @returns {Promise<object>}
101
+ * @throws {Error} If the form file does not exist or cannot be parsed.
102
+ */
103
+ export async function getForm(slug) {
104
+ return readForm(slug);
105
+ }
106
+
107
+ /**
108
+ * Create a new form definition and write it to disk.
109
+ *
110
+ * @param {object} data - Form definition data
111
+ * @param {string} data.title - Form title (required if no slug)
112
+ * @param {string} [data.slug] - Explicit slug (derived from title if omitted)
113
+ * @param {string} [data.description]
114
+ * @param {Array} [data.fields]
115
+ * @param {object} [data.settings]
116
+ * @param {object} [data.actions]
117
+ * @returns {Promise<object>} The created form object
118
+ * @throws {Error} If title is missing, slug cannot be derived, or a form with the slug already exists.
119
+ */
120
+ export async function createForm(data) {
121
+ const { title, slug: rawSlug, description = '', fields = [], settings = {}, actions = {}, plugin } = data || {};
122
+
123
+ if (!title?.trim() && !rawSlug?.trim()) {
124
+ throw new Error('A title or slug is required to create a form.');
125
+ }
126
+
127
+ const slug = rawSlug ? slugify(rawSlug) : slugify(title);
128
+ if (!slug) {
129
+ throw new Error('Could not derive a valid slug from the provided title or slug.');
130
+ }
131
+
132
+ // Throw if a form with this slug already exists
133
+ try {
134
+ await readForm(slug);
135
+ const err = new Error(`Form with slug "${slug}" already exists`);
136
+ err.code = 'FORM_ALREADY_EXISTS';
137
+ throw err;
138
+ } catch (err) {
139
+ if (err.code === 'FORM_ALREADY_EXISTS') throw err;
140
+ // File not found — safe to proceed
141
+ }
142
+
143
+ const now = new Date().toISOString();
144
+ const trimmedTitle = title ? title.trim() : slug;
145
+
146
+ const form = {
147
+ slug,
148
+ title: trimmedTitle,
149
+ description,
150
+ ...(plugin ? {plugin} : {}),
151
+ fields: Array.isArray(fields) ? fields : [],
152
+ settings: {
153
+ submitText: 'Submit',
154
+ successMessage: 'Thank you for your submission.',
155
+ layout: 'stacked',
156
+ honeypot: true,
157
+ rateLimitPerMinute: 3,
158
+ ...settings
159
+ },
160
+ actions: {
161
+ email: { enabled: false, recipients: '', subjectPrefix: `[${trimmedTitle}]` },
162
+ webhook: { enabled: false, url: '', method: 'POST' },
163
+ collection: { enabled: true, slug },
164
+ ...actions
165
+ },
166
+ createdAt: now,
167
+ updatedAt: now
168
+ };
169
+
170
+ await writeForm(slug, form);
171
+ return form;
172
+ }
173
+
174
+ /** System collections that should never have an auto-generated public form. */
175
+ const NO_FORM_SLUGS = new Set(['roles', 'user-profiles']);
176
+
177
+ /**
178
+ * Collection field type → form field type mapping.
179
+ *
180
+ * @param {string} type
181
+ * @returns {string}
182
+ */
183
+ function toFormFieldType(type) {
184
+ const map = {
185
+ string: 'string', text: 'string', email: 'email', tel: 'tel',
186
+ number: 'number', textarea: 'textarea', select: 'select', radio: 'radio',
187
+ checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
188
+ date: 'date', time: 'time', url: 'url', hidden: 'hidden'
189
+ };
190
+ return map[type] || 'string';
191
+ }
192
+
193
+ /**
194
+ * Build a form definition object from a collection schema.
195
+ *
196
+ * @param {object} schema - Collection schema
197
+ * @returns {object} Form definition
198
+ */
199
+ function buildFormFromCollection(schema) {
200
+ const now = new Date().toISOString();
201
+ const fields = (schema.fields || []).map(f => {
202
+ const field = {
203
+ name: f.name,
204
+ type: toFormFieldType(f.type),
205
+ label: f.label || f.name,
206
+ required: !!f.required,
207
+ placeholder: f.placeholder || '',
208
+ helper: f.helper || '',
209
+ tooltip: f.tooltip || ''
210
+ };
211
+ if (f.options) field.options = f.options;
212
+ if (f.validation) field.validation = f.validation;
213
+ return field;
214
+ });
215
+
216
+ return {
217
+ slug: schema.slug,
218
+ title: schema.title,
219
+ description: schema.description || '',
220
+ fields,
221
+ settings: {
222
+ submitText: 'Submit',
223
+ successMessage: 'Thank you for your submission.',
224
+ layout: 'stacked',
225
+ honeypot: true,
226
+ rateLimitPerMinute: 5
227
+ },
228
+ actions: {
229
+ email: {enabled: false, recipients: '', subjectPrefix: `[${schema.slug}]`},
230
+ webhook: {enabled: false, url: '', method: 'POST'},
231
+ collection: {enabled: true, slug: schema.slug}
232
+ },
233
+ createdAt: now,
234
+ updatedAt: now
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Ensure a form exists for the given collection schema.
240
+ * Creates the form only if absent — never overwrites an existing one.
241
+ * Skips system collections (roles, user-profiles).
242
+ *
243
+ * @param {object} schema - Collection schema
244
+ * @returns {Promise<void>}
245
+ */
246
+ export async function ensureFormForCollection(schema) {
247
+ if (NO_FORM_SLUGS.has(schema.slug)) return;
248
+ await ensureFormsDir();
249
+ const filePath = path.join(FORMS_DIR, `${schema.slug}.json`);
250
+ try {
251
+ await fs.access(filePath);
252
+ } catch {
253
+ await writeForm(schema.slug, buildFormFromCollection(schema));
254
+ }
255
+ }
256
+
257
+ /** Form field types that are presentational only and store no value. */
258
+ const NON_DATA_FIELD_TYPES = new Set(['page-break', 'spacer', 'heading', 'paragraph', 'html']);
259
+
260
+ /**
261
+ * Form field type → collection field type. Mostly an identity mapping; a form's
262
+ * field types are a superset of (and align with) collection field types.
263
+ *
264
+ * @param {string} type
265
+ * @returns {string}
266
+ */
267
+ function toCollectionFieldType(type) {
268
+ const map = {
269
+ string: 'string', email: 'email', tel: 'tel', number: 'number',
270
+ textarea: 'textarea', select: 'select', radio: 'radio',
271
+ checkbox: 'checkbox', 'checkbox-group': 'checkbox-group',
272
+ date: 'date', time: 'time', url: 'url', hidden: 'hidden'
273
+ };
274
+ return map[type] || 'string';
275
+ }
276
+
277
+ /**
278
+ * Ensure the collection that backs a form's submissions exists.
279
+ *
280
+ * This is the inverse of {@link ensureFormForCollection}. A form whose
281
+ * `collection` action points at a slug that was never created is permanently
282
+ * broken: listing submissions 404s and — worse — every public submission is
283
+ * rejected and lost. Provisioning the collection from the form's own fields
284
+ * makes such forms self-heal instead of silently dropping data.
285
+ *
286
+ * Creates the collection only if absent — never overwrites an existing one.
287
+ * No-op when the form disables collection storage.
288
+ *
289
+ * @param {object} form - Form definition
290
+ * @returns {Promise<object|null>} The existing or newly created collection schema, or null when not applicable.
291
+ */
292
+ export async function ensureCollectionForForm(form) {
293
+ if (!form || typeof form !== 'object') return null;
294
+
295
+ const action = form.actions?.collection;
296
+ // Respect an explicitly disabled collection store — nothing to provision.
297
+ if (action && action.enabled === false) return null;
298
+
299
+ const targetSlug = (action && action.slug) ? action.slug : form.slug;
300
+ if (!targetSlug) return null;
301
+
302
+ // Dynamic import avoids a forms ↔ collections module cycle at load time.
303
+ const { getCollection, createCollection } = await import('./collections.js');
304
+
305
+ const existing = await getCollection(targetSlug);
306
+ if (existing) return existing;
307
+
308
+ const fields = (form.fields || [])
309
+ .filter(f => f && f.name && !NON_DATA_FIELD_TYPES.has(f.type))
310
+ .map(f => {
311
+ const field = {
312
+ name: f.name,
313
+ type: toCollectionFieldType(f.type),
314
+ label: f.label || f.name,
315
+ required: !!f.required
316
+ };
317
+ if (f.options) field.options = f.options;
318
+ if (f.validation) field.validation = f.validation;
319
+ return field;
320
+ });
321
+
322
+ try {
323
+ return await createCollection({
324
+ title: form.title || targetSlug,
325
+ slug: targetSlug,
326
+ description: form.description || `Submissions for the "${form.title || targetSlug}" form.`,
327
+ fields,
328
+ // Submissions can contain PII — lock the backing collection to admin
329
+ // access so it is never exposed through the public collections API.
330
+ // (Mirrors the access config the POST /forms create path applies.)
331
+ api: {
332
+ create: { enabled: false, access: 'admin' },
333
+ read: { enabled: true, access: 'admin' },
334
+ update: { enabled: false, access: 'admin' },
335
+ delete: { enabled: false, access: 'admin' }
336
+ },
337
+ meta: { generatedFromForm: form.slug }
338
+ });
339
+ } catch (err) {
340
+ // Lost a race with a concurrent provision (EEXIST) — re-read and use it.
341
+ const now = await getCollection(targetSlug);
342
+ if (now) return now;
343
+ throw err;
344
+ }
345
+ }
@@ -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
 
@@ -50,7 +50,33 @@ const PRESETS = [
50
50
  {name: 'description', label: 'Description', type: 'textarea'},
51
51
  {name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
52
52
  {name: 'rootUrl', label: 'Root URL', type: 'text'},
53
- {name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0}
53
+ {name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
54
+ {name: 'protected', label: 'Protected', type: 'boolean', default: false}
55
+ ],
56
+ api: {
57
+ create: {enabled: false, access: 'admin'},
58
+ read: {enabled: false, access: 'admin'},
59
+ update: {enabled: false, access: 'admin'},
60
+ delete: {enabled: false, access: 'admin'}
61
+ }
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'}
54
80
  ],
55
81
  api: {
56
82
  create: {enabled: false, access: 'admin'},