domma-cms 0.25.16 → 0.30.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/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/effects.html +752 -752
- package/admin/js/templates/forms.html +17 -17
- package/admin/js/templates/my-profile.html +17 -17
- package/admin/js/templates/project-settings.html +7 -0
- package/admin/js/templates/role-editor.html +70 -70
- package/admin/js/templates/roles.html +10 -10
- package/admin/js/views/project-settings.js +1 -1
- package/admin/js/views/projects.js +3 -7
- package/bin/lib/config-merge.js +44 -44
- package/config/connections.json.bak +9 -0
- package/package.json +1 -1
- package/public/js/site.js +1 -1
- package/scripts/migrate-docs.js +280 -0
- package/server/middleware/auth.js +253 -253
- package/server/routes/api/auth.js +309 -309
- package/server/routes/api/collections.js +10 -1
- package/server/routes/api/navigation.js +42 -42
- package/server/routes/api/projects.js +9 -1
- package/server/routes/api/settings.js +141 -141
- package/server/routes/public.js +9 -0
- package/server/server.js +3 -1
- package/server/services/email.js +167 -167
- package/server/services/presetCollections.js +1 -0
- package/server/services/projects.js +72 -3
- package/server/services/userProfiles.js +199 -199
- package/server/services/users.js +302 -302
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
|
+
}
|
|
@@ -51,6 +51,7 @@ const PRESETS = [
|
|
|
51
51
|
{name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
|
|
52
52
|
{name: 'rootUrl', label: 'Root URL', type: 'text'},
|
|
53
53
|
{name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0},
|
|
54
|
+
{name: 'enabled', label: 'Enabled', type: 'boolean', default: true},
|
|
54
55
|
{name: 'protected', label: 'Protected', type: 'boolean', default: false}
|
|
55
56
|
],
|
|
56
57
|
api: {
|
|
@@ -36,6 +36,23 @@ const CORE_PROJECT_SEED = {
|
|
|
36
36
|
icon: 'home',
|
|
37
37
|
rootUrl: '/',
|
|
38
38
|
sortOrder: -1,
|
|
39
|
+
enabled: true,
|
|
40
|
+
protected: true
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Slug of the built-in, disabled-by-default documentation project. */
|
|
44
|
+
export const DOCS_PROJECT_SLUG = 'domma-docs';
|
|
45
|
+
|
|
46
|
+
/** Seed record for the Domma Docs project (created at boot if absent). */
|
|
47
|
+
const DOCS_PROJECT_SEED = {
|
|
48
|
+
slug: DOCS_PROJECT_SLUG,
|
|
49
|
+
name: 'Domma Docs',
|
|
50
|
+
description: 'The Domma CMS handbook — guides, tutorials, component & API ' +
|
|
51
|
+
'reference. Switch on to publish it on your own site.',
|
|
52
|
+
icon: 'document',
|
|
53
|
+
rootUrl: '/domma-docs',
|
|
54
|
+
sortOrder: 0,
|
|
55
|
+
enabled: false,
|
|
39
56
|
protected: true
|
|
40
57
|
};
|
|
41
58
|
|
|
@@ -105,6 +122,7 @@ export async function createProject(input) {
|
|
|
105
122
|
icon: input.icon || 'folder',
|
|
106
123
|
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
107
124
|
sortOrder: Number.isFinite(input.sortOrder) ? input.sortOrder : 0,
|
|
125
|
+
enabled: input.enabled !== false,
|
|
108
126
|
...(typeof input.createdBy === 'string' && input.createdBy.trim() && {createdBy: input.createdBy.trim()}),
|
|
109
127
|
createdAt: now,
|
|
110
128
|
updatedAt: now
|
|
@@ -127,8 +145,9 @@ export async function seedCoreProject() {
|
|
|
127
145
|
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
128
146
|
const existing = entries.find(e => e.data?.slug === CORE_PROJECT_SLUG);
|
|
129
147
|
if (existing) {
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// Core is always protected and always enabled — adopt both invariants.
|
|
149
|
+
if (existing.data.protected === true && existing.data.enabled !== false) return normalise(existing);
|
|
150
|
+
const adopted = {...existing.data, protected: true, enabled: true, updatedAt: new Date().toISOString()};
|
|
132
151
|
await updateEntry(PROJECTS_COLLECTION_SLUG, existing.id, adopted);
|
|
133
152
|
return adopted;
|
|
134
153
|
}
|
|
@@ -138,6 +157,24 @@ export async function seedCoreProject() {
|
|
|
138
157
|
return data;
|
|
139
158
|
}
|
|
140
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Seed the built-in Domma Docs project at boot. Create-if-absent by slug — an
|
|
162
|
+
* existing record (including the admin's enabled/disabled choice and any edits)
|
|
163
|
+
* is returned untouched. Disabled by default: a fresh site ships with the docs
|
|
164
|
+
* off until an admin enables the project.
|
|
165
|
+
*
|
|
166
|
+
* @returns {Promise<object>} The docs project record.
|
|
167
|
+
*/
|
|
168
|
+
export async function seedDocsProject() {
|
|
169
|
+
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
170
|
+
const existing = entries.find(e => e.data?.slug === DOCS_PROJECT_SLUG);
|
|
171
|
+
if (existing) return normalise(existing);
|
|
172
|
+
const now = new Date().toISOString();
|
|
173
|
+
const data = {...DOCS_PROJECT_SEED, createdAt: now, updatedAt: now};
|
|
174
|
+
await createEntry(PROJECTS_COLLECTION_SLUG, data);
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
177
|
+
|
|
141
178
|
/**
|
|
142
179
|
* Update a project. Slug is immutable. Preserves createdAt; bumps updatedAt.
|
|
143
180
|
* The core project's `rootUrl` and `protected` flag are locked; its name,
|
|
@@ -159,6 +196,9 @@ export async function updateProject(slug, input) {
|
|
|
159
196
|
if (input.protected != null && input.protected !== existing.protected) {
|
|
160
197
|
throw new Error("Cannot change the core project's protected flag");
|
|
161
198
|
}
|
|
199
|
+
if (input.enabled === false) {
|
|
200
|
+
throw new Error('Cannot disable the core project');
|
|
201
|
+
}
|
|
162
202
|
}
|
|
163
203
|
const merged = {
|
|
164
204
|
...existing,
|
|
@@ -167,6 +207,7 @@ export async function updateProject(slug, input) {
|
|
|
167
207
|
...(input.icon != null && {icon: input.icon}),
|
|
168
208
|
...(input.rootUrl != null && {rootUrl: input.rootUrl}),
|
|
169
209
|
...(Number.isFinite(input.sortOrder) && {sortOrder: input.sortOrder}),
|
|
210
|
+
...(typeof input.enabled === 'boolean' && {enabled: input.enabled}),
|
|
170
211
|
slug,
|
|
171
212
|
createdAt: existing.createdAt,
|
|
172
213
|
updatedAt: new Date().toISOString()
|
|
@@ -196,6 +237,9 @@ export async function deleteProject(slug) {
|
|
|
196
237
|
const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
|
|
197
238
|
const entry = entries.find(e => e.data?.slug === slug);
|
|
198
239
|
if (!entry) return false;
|
|
240
|
+
if (entry.data?.protected === true) {
|
|
241
|
+
throw new Error(`Cannot delete the protected "${slug}" project`);
|
|
242
|
+
}
|
|
199
243
|
await deleteEntry(PROJECTS_COLLECTION_SLUG, entry.id);
|
|
200
244
|
return true;
|
|
201
245
|
}
|
|
@@ -229,7 +273,28 @@ export function validateProject(project) {
|
|
|
229
273
|
}
|
|
230
274
|
|
|
231
275
|
function normalise(entry) {
|
|
232
|
-
|
|
276
|
+
const data = entry.data || {};
|
|
277
|
+
// `enabled` is default-on: legacy records (and any record missing the
|
|
278
|
+
// field) are treated as enabled. Only an explicit `false` disables.
|
|
279
|
+
return {...data, enabled: data.enabled !== false};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Whether the given project is enabled (i.e. not switched off). A disabled
|
|
284
|
+
* project is a hard kill-switch on its PUBLIC surfaces — public pages 404 and
|
|
285
|
+
* its external API (collections + custom endpoints + tokens) is refused.
|
|
286
|
+
*
|
|
287
|
+
* The core project is always enabled. An unknown slug resolves to core by
|
|
288
|
+
* fallback elsewhere, so it is reported enabled here (never a block).
|
|
289
|
+
*
|
|
290
|
+
* @param {string} slug
|
|
291
|
+
* @returns {Promise<boolean>}
|
|
292
|
+
*/
|
|
293
|
+
export async function isProjectEnabled(slug) {
|
|
294
|
+
if (!slug || slug === CORE_PROJECT_SLUG) return true;
|
|
295
|
+
const project = await getProject(slug);
|
|
296
|
+
if (!project) return true;
|
|
297
|
+
return project.enabled !== false;
|
|
233
298
|
}
|
|
234
299
|
|
|
235
300
|
/**
|
|
@@ -455,6 +520,10 @@ export async function untagAllForProject(projectSlug) {
|
|
|
455
520
|
if (projectSlug === CORE_PROJECT_SLUG) {
|
|
456
521
|
throw new Error('Cannot untag the core project — artefacts would immediately resolve back to it');
|
|
457
522
|
}
|
|
523
|
+
const proj = await getProject(projectSlug);
|
|
524
|
+
if (proj?.protected === true) {
|
|
525
|
+
throw new Error(`Cannot untag the protected "${projectSlug}" project — its pages would resolve back to core`);
|
|
526
|
+
}
|
|
458
527
|
const counts = {
|
|
459
528
|
pages: 0, collections: 0, forms: 0, actions: 0,
|
|
460
529
|
menus: 0, blocks: 0, components: 0, views: 0, roles: 0, users: 0, apis: 0
|