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.
- package/CLAUDE.md +16 -5
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/crud-tutorial.js +1 -1
- package/admin/js/lib/project-context.js +1 -1
- package/admin/js/templates/api-tokens.html +13 -0
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/form-submissions.html +30 -30
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/project-settings.html +1 -1
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-tokens.js +8 -0
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -3
- package/admin/js/views/roles.js +1 -1
- package/bin/lib/config-merge.js +44 -44
- package/bin/update.js +547 -547
- package/config/menus/admin-sidebar.json +7 -1
- package/package.json +3 -2
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +113 -16
- package/server/routes/api/forms.js +765 -746
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/projects.js +9 -2
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +10 -1
- package/server/services/apiTokens.js +259 -0
- package/server/services/email.js +167 -167
- package/server/services/forms.js +345 -255
- package/server/services/permissionRegistry.js +13 -0
- package/server/services/presetCollections.js +27 -1
- package/server/services/projects.js +115 -24
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +31 -1
- package/server/services/sidebar-migration.js +44 -0
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
- package/config/connections.json.bak +0 -9
package/server/services/forms.js
CHANGED
|
@@ -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'},
|