domma-cms 0.23.0 → 0.25.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 +14 -0
- 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-endpoint-editor.html +120 -0
- package/admin/js/templates/api-endpoints.html +13 -0
- 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/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/api-endpoint-editor.js +1 -0
- package/admin/js/views/api-endpoints.js +7 -0
- 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-detail.js +1 -1
- 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 +13 -1
- package/package.json +1 -1
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/api-endpoints.js +96 -0
- package/server/routes/api/api-tokens.js +83 -0
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +114 -17
- package/server/routes/api/endpoints-public.js +88 -0
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +202 -202
- package/server/server.js +16 -1
- package/server/services/apiEndpoints.js +402 -0
- package/server/services/apiTokens.js +273 -0
- package/server/services/email.js +167 -167
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/presetCollections.js +54 -0
- package/server/services/projects.js +18 -2
- package/server/services/roles.js +16 -0
- package/server/services/scaffolder.js +54 -1
- package/server/services/sidebar-migration.js +45 -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/email.js
CHANGED
|
@@ -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, '&')
|
|
42
|
-
.replace(/</g, '<')
|
|
43
|
-
.replace(/>/g, '>')
|
|
44
|
-
.replace(/"/g, '"')
|
|
45
|
-
.replace(/'/g, ''');
|
|
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, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/"/g, '"')
|
|
45
|
+
.replace(/'/g, ''');
|
|
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,32 @@ 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
|
+
]
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
key: 'api-endpoints',
|
|
221
|
+
label: 'API Builder',
|
|
222
|
+
description: 'Build custom REST endpoints over collection data (/api/x/*).',
|
|
223
|
+
icon: 'code',
|
|
224
|
+
group: 'Configuration',
|
|
225
|
+
actions: [
|
|
226
|
+
{key: 'read', label: 'View', description: 'View custom API endpoint definitions'},
|
|
227
|
+
{key: 'create', label: 'Create', description: 'Create new endpoint definitions'},
|
|
228
|
+
{key: 'update', label: 'Edit', description: 'Edit endpoint definitions'},
|
|
229
|
+
{key: 'delete', label: 'Delete', description: 'Delete endpoint definitions'}
|
|
230
|
+
]
|
|
205
231
|
}
|
|
206
232
|
];
|
|
207
233
|
|
|
@@ -60,6 +60,60 @@ 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
|
+
}
|
|
88
|
+
,
|
|
89
|
+
{
|
|
90
|
+
slug: 'api-endpoints',
|
|
91
|
+
title: 'API Endpoints',
|
|
92
|
+
description: 'Curated custom REST endpoints over collection data (/api/x/*).',
|
|
93
|
+
preset: true,
|
|
94
|
+
systemManaged: true,
|
|
95
|
+
fields: [
|
|
96
|
+
{name: 'name', label: 'Name', type: 'text', required: true},
|
|
97
|
+
{name: 'project', label: 'Project', type: 'text', required: true},
|
|
98
|
+
{name: 'path', label: 'Path', type: 'text', required: true},
|
|
99
|
+
{name: 'collection', label: 'Collection', type: 'text', required: true},
|
|
100
|
+
{name: 'auth', label: 'Auth', type: 'text', default: 'public'},
|
|
101
|
+
{name: 'mode', label: 'Mode', type: 'select', options: ['list', 'single'], default: 'list'},
|
|
102
|
+
{name: 'filter', label: 'Filter', type: 'object', default: {}},
|
|
103
|
+
{name: 'sort', label: 'Sort Field', type: 'text'},
|
|
104
|
+
{name: 'order', label: 'Sort Order', type: 'select', options: ['asc', 'desc'], default: 'desc'},
|
|
105
|
+
{name: 'limit', label: 'Limit', type: 'number', default: 50},
|
|
106
|
+
{name: 'fields', label: 'Fields', type: 'array', items: 'string', default: []},
|
|
107
|
+
{name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
|
|
108
|
+
{name: 'createdBy', label: 'Created By', type: 'text'}
|
|
109
|
+
],
|
|
110
|
+
api: {
|
|
111
|
+
create: {enabled: false, access: 'admin'},
|
|
112
|
+
read: {enabled: false, access: 'admin'},
|
|
113
|
+
update: {enabled: false, access: 'admin'},
|
|
114
|
+
delete: {enabled: false, access: 'admin'}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
63
117
|
];
|
|
64
118
|
|
|
65
119
|
/** Slugs exported for use in adapterRegistry and the delete guard. */
|
|
@@ -337,7 +337,7 @@ export async function getProjectForPage(urlPath, explicitProject) {
|
|
|
337
337
|
export async function getArtefactsForProject(projectSlug) {
|
|
338
338
|
const out = {
|
|
339
339
|
pages: [], collections: [], forms: [], actions: [],
|
|
340
|
-
menus: [], blocks: [], views: [], roles: [], users: []
|
|
340
|
+
menus: [], blocks: [], views: [], roles: [], users: [], apis: []
|
|
341
341
|
};
|
|
342
342
|
|
|
343
343
|
try {
|
|
@@ -411,6 +411,17 @@ export async function getArtefactsForProject(projectSlug) {
|
|
|
411
411
|
}
|
|
412
412
|
} catch { /* skip */ }
|
|
413
413
|
|
|
414
|
+
try {
|
|
415
|
+
// Custom API endpoints store their project in data.project (required
|
|
416
|
+
// field — no untagged-→core fallback applies, so match directly).
|
|
417
|
+
const {entries} = await listEntries('api-endpoints', {limit: 0});
|
|
418
|
+
for (const e of entries) {
|
|
419
|
+
if (e.data?.project === projectSlug) {
|
|
420
|
+
out.apis.push({id: e.id, name: e.data.name, path: e.data.path, collection: e.data.collection});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch { /* skip */ }
|
|
424
|
+
|
|
414
425
|
return out;
|
|
415
426
|
}
|
|
416
427
|
|
|
@@ -436,7 +447,7 @@ export async function untagAllForProject(projectSlug) {
|
|
|
436
447
|
}
|
|
437
448
|
const counts = {
|
|
438
449
|
pages: 0, collections: 0, forms: 0, actions: 0,
|
|
439
|
-
menus: 0, blocks: 0, views: 0, roles: 0, users: 0
|
|
450
|
+
menus: 0, blocks: 0, views: 0, roles: 0, users: 0, apis: 0
|
|
440
451
|
};
|
|
441
452
|
const grouped = await getArtefactsForProject(projectSlug);
|
|
442
453
|
|
|
@@ -516,6 +527,11 @@ export async function untagAllForProject(projectSlug) {
|
|
|
516
527
|
// plumbing to land first; pages need frontmatter rewriting which is a
|
|
517
528
|
// separate concern. Counts remain 0 for those types in this task; later
|
|
518
529
|
// tasks may revisit.
|
|
530
|
+
//
|
|
531
|
+
// API endpoints: intentionally skipped. An endpoint's project IS its URL
|
|
532
|
+
// namespace (/api/x/<project>/...) — silently untagging would move live
|
|
533
|
+
// endpoints to /api/x/core/... and break external callers. Delete or
|
|
534
|
+
// recreate them explicitly instead.
|
|
519
535
|
|
|
520
536
|
return counts;
|
|
521
537
|
}
|
package/server/services/roles.js
CHANGED
|
@@ -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
|
}
|
|
@@ -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: [], apiEndpoints: [] };
|
|
283
283
|
const skipped = [];
|
|
284
284
|
const warnings = [];
|
|
285
285
|
|
|
@@ -459,6 +459,59 @@ 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
|
+
|
|
492
|
+
// Custom API endpoints — declared as definition objects (path, collection,
|
|
493
|
+
// filter, ...). Idempotent: an existing (project, path-shape) match is
|
|
494
|
+
// skipped so re-applying a recipe never clobbers a tuned definition.
|
|
495
|
+
const endpointDecls = resolved.apiEndpoints ? [].concat(resolved.apiEndpoints) : [];
|
|
496
|
+
if (endpointDecls.length) {
|
|
497
|
+
const {createEndpoint, findEndpointByPath} = await import('./apiEndpoints.js');
|
|
498
|
+
const epProject = projectSlug || tokens.namespace || 'core';
|
|
499
|
+
for (const ep of endpointDecls) {
|
|
500
|
+
if (!ep.path) continue;
|
|
501
|
+
if (await findEndpointByPath(epProject, ep.path)) {
|
|
502
|
+
skipped.push(`apiEndpoint:${ep.path}`);
|
|
503
|
+
warnings.push(`API endpoint "${ep.path}" already exists for project "${epProject}" — left unchanged`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
await createEndpoint({...ep, project: epProject, createdBy: opts.createdBy || null});
|
|
508
|
+
created.apiEndpoints.push(ep.path);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
warnings.push(`API endpoint "${ep.path}" failed: ${err.message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
462
515
|
const snippet = resolved.snippet || null;
|
|
463
516
|
|
|
464
517
|
return { created, skipped, warnings, snippet };
|
|
@@ -41,6 +41,7 @@ const SEED_ITEMS = [
|
|
|
41
41
|
{text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
|
|
42
42
|
{text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
|
|
43
43
|
{text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions'},
|
|
44
|
+
{text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints'},
|
|
44
45
|
{text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
|
|
45
46
|
{text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
|
|
46
47
|
]
|
|
@@ -55,6 +56,7 @@ const SEED_ITEMS = [
|
|
|
55
56
|
{text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
|
|
56
57
|
{text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
|
|
57
58
|
{text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
|
|
59
|
+
{text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens'},
|
|
58
60
|
{text: 'My Profile', url: '#/my-profile', icon: 'user'}
|
|
59
61
|
]
|
|
60
62
|
},
|
|
@@ -115,3 +117,46 @@ export async function runMigration(opts = {}) {
|
|
|
115
117
|
console.log('[admin-sidebar] Seeded admin-sidebar menu + mapped admin-sidebar slot');
|
|
116
118
|
return {migrated: true};
|
|
117
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Append-if-absent: ensure a single item exists in the persisted admin
|
|
123
|
+
* sidebar menu. Lets updates surface new admin pages on EXISTING installs
|
|
124
|
+
* (the seed above only reaches fresh ones) without clobbering admin edits:
|
|
125
|
+
* if any node in the tree already has the item's url — even moved, renamed,
|
|
126
|
+
* or hidden — this is a no-op.
|
|
127
|
+
*
|
|
128
|
+
* Returns `{ensured: boolean, reason?: string}`.
|
|
129
|
+
*
|
|
130
|
+
* @param {{groupText: string, item: {text: string, url: string, icon?: string, permission?: string}}} spec
|
|
131
|
+
* @param {{configDir?: string}} [opts]
|
|
132
|
+
*/
|
|
133
|
+
export async function ensureSidebarItem({groupText, item}, opts = {}) {
|
|
134
|
+
const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
|
|
135
|
+
const menuPath = path.join(configDir, 'menus', 'admin-sidebar.json');
|
|
136
|
+
|
|
137
|
+
if (!await exists(menuPath)) {
|
|
138
|
+
return {ensured: false, reason: 'admin-sidebar.json not present'};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const menu = await readJson(menuPath);
|
|
142
|
+
const items = Array.isArray(menu.items) ? menu.items : (menu.items = []);
|
|
143
|
+
|
|
144
|
+
const hasUrl = (nodes) => nodes.some(n => n?.url === item.url || (Array.isArray(n?.items) && hasUrl(n.items)));
|
|
145
|
+
if (hasUrl(items)) {
|
|
146
|
+
return {ensured: false, reason: 'item already present'};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let group = items.find(n => typeof n?.text === 'string' && n.text.toLowerCase() === groupText.toLowerCase());
|
|
150
|
+
if (!group) {
|
|
151
|
+
group = {text: groupText, items: []};
|
|
152
|
+
items.push(group);
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(group.items)) group.items = [];
|
|
155
|
+
group.items.push(item);
|
|
156
|
+
|
|
157
|
+
menu.meta = {...(menu.meta || {}), updatedAt: new Date().toISOString()};
|
|
158
|
+
await writeJson(menuPath, menu);
|
|
159
|
+
|
|
160
|
+
console.log(`[admin-sidebar] Added "${item.text}" to the ${groupText} group`);
|
|
161
|
+
return {ensured: true};
|
|
162
|
+
}
|