kempo 4.2.10 → 4.2.12
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/app-public/.config.json +2 -5
- package/bin/cli.js +35 -14
- package/dist/admin/accounts/groups/[name]/index.html +518 -0
- package/dist/admin/accounts/groups/index.html +378 -0
- package/dist/admin/accounts/permissions/index.html +397 -0
- package/dist/admin/accounts/users/[id]/index.html +541 -0
- package/dist/admin/accounts/users/index.html +396 -0
- package/dist/admin/content/fragments/edit/index.html +132 -0
- package/dist/admin/content/fragments/index.html +375 -0
- package/dist/admin/content/globals/edit/index.html +132 -0
- package/dist/admin/content/globals/index.html +358 -0
- package/dist/admin/content/pages/edit/index.html +132 -0
- package/dist/admin/content/pages/index.html +509 -0
- package/dist/admin/content/templates/edit/index.html +132 -0
- package/dist/admin/content/templates/index.html +429 -0
- package/dist/admin/extensions/index.html +222 -0
- package/dist/admin/index.html +130 -0
- package/dist/admin/index.page.html +1 -0
- package/dist/admin/settings/[owner]/[name]/index.html +449 -0
- package/dist/admin/settings/index.html +410 -0
- package/middleware/{extension-scope-router.js → kempo.js} +40 -25
- package/package.json +3 -3
- package/middleware/kempo-auth.js +0 -26
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
owner: system
|
|
3
|
+
locked: true
|
|
4
|
+
name:
|
|
5
|
+
author:
|
|
6
|
+
created:
|
|
7
|
+
updated:
|
|
8
|
+
-->
|
|
9
|
+
<!DOCTYPE html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="UTF-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
14
|
+
<link rel="icon" type="image/png" sizes="48x48" href="/media/icon48.png">
|
|
15
|
+
<link rel="manifest" href="/manifest.json">
|
|
16
|
+
<title>Settings - Admin</title>
|
|
17
|
+
<link rel="stylesheet" href="/kempo-css/kempo.min.css">
|
|
18
|
+
<script src="/admin/init.js"></script>
|
|
19
|
+
|
|
20
|
+
</head>
|
|
21
|
+
<body>
|
|
22
|
+
<!--
|
|
23
|
+
locked: true
|
|
24
|
+
-->
|
|
25
|
+
|
|
26
|
+
<k-aside
|
|
27
|
+
main="push"
|
|
28
|
+
state="expanded"
|
|
29
|
+
style="--aside_padding: 0;"
|
|
30
|
+
persistent-id="admin-nav"
|
|
31
|
+
>
|
|
32
|
+
<k-aside-toggle>
|
|
33
|
+
<h3 class="m0">Kempo Admin</h3>
|
|
34
|
+
</k-aside-toggle>
|
|
35
|
+
<k-aside-item
|
|
36
|
+
icon="preview"
|
|
37
|
+
href="/"
|
|
38
|
+
no-expand
|
|
39
|
+
>Site</k-aside-item>
|
|
40
|
+
<k-aside-item
|
|
41
|
+
icon="cards"
|
|
42
|
+
href="/admin"
|
|
43
|
+
no-expand
|
|
44
|
+
>Dashboard</k-aside-item>
|
|
45
|
+
<k-aside-menu
|
|
46
|
+
icon="manage-accounts"
|
|
47
|
+
label="Accounts"
|
|
48
|
+
>
|
|
49
|
+
<k-aside-item
|
|
50
|
+
icon="person"
|
|
51
|
+
href="/admin/accounts/users"
|
|
52
|
+
no-expand
|
|
53
|
+
>Users</k-aside-item>
|
|
54
|
+
<k-aside-item
|
|
55
|
+
icon="group"
|
|
56
|
+
href="/admin/accounts/groups"
|
|
57
|
+
no-expand
|
|
58
|
+
>Groups</k-aside-item>
|
|
59
|
+
<k-aside-item
|
|
60
|
+
icon="lock"
|
|
61
|
+
href="/admin/accounts/permissions"
|
|
62
|
+
no-expand
|
|
63
|
+
>Permissions</k-aside-item>
|
|
64
|
+
</k-aside-menu>
|
|
65
|
+
<k-aside-menu
|
|
66
|
+
icon="cards_stack"
|
|
67
|
+
label="Content"
|
|
68
|
+
>
|
|
69
|
+
<k-aside-item
|
|
70
|
+
icon="browse"
|
|
71
|
+
href="/admin/content/templates"
|
|
72
|
+
>Templates</k-aside-item>
|
|
73
|
+
<k-aside-item
|
|
74
|
+
icon="docs"
|
|
75
|
+
href="/admin/content/pages"
|
|
76
|
+
>Pages</k-aside-item>
|
|
77
|
+
<k-aside-item
|
|
78
|
+
icon="content"
|
|
79
|
+
href="/admin/content/fragments"
|
|
80
|
+
>Fragments</k-aside-item>
|
|
81
|
+
<k-aside-item
|
|
82
|
+
icon="globe"
|
|
83
|
+
href="/admin/content/globals"
|
|
84
|
+
>Global Content</k-aside-item>
|
|
85
|
+
</k-aside-menu>
|
|
86
|
+
<k-aside-item
|
|
87
|
+
icon="settings"
|
|
88
|
+
href="/admin/settings"
|
|
89
|
+
no-expand
|
|
90
|
+
>Settings</k-aside-item>
|
|
91
|
+
<k-aside-item
|
|
92
|
+
icon="extension"
|
|
93
|
+
href="/admin/extensions"
|
|
94
|
+
no-expand
|
|
95
|
+
>Extensions</k-aside-item>
|
|
96
|
+
<hr>
|
|
97
|
+
<k-aside-spacer></k-aside-spacer>
|
|
98
|
+
<k-theme-switcher
|
|
99
|
+
labels
|
|
100
|
+
style="--padding:var(--spacer_h);margin:var(--spacer_h) auto"
|
|
101
|
+
></k-theme-switcher>
|
|
102
|
+
</k-aside>
|
|
103
|
+
<script type="module">
|
|
104
|
+
import '/kempo-ui/components/ThemeSwitcher.js';
|
|
105
|
+
const items = document.querySelectorAll('k-aside-item');
|
|
106
|
+
const currentPath = window.location.pathname;
|
|
107
|
+
|
|
108
|
+
items.forEach(item => {
|
|
109
|
+
const href = item.getAttribute('href');
|
|
110
|
+
if (!href) return;
|
|
111
|
+
const isExact = href === '/admin' || href === '/';
|
|
112
|
+
const match = isExact
|
|
113
|
+
? (currentPath === href || currentPath === href + '/')
|
|
114
|
+
: (currentPath === href || currentPath.startsWith(href + '/'));
|
|
115
|
+
if (match) {
|
|
116
|
+
item.setAttribute('active', '');
|
|
117
|
+
const menu = item.closest('k-aside-menu');
|
|
118
|
+
if (menu) menu.setAttribute('open', '');
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<k-main>
|
|
124
|
+
|
|
125
|
+
<script type="module" src="/kempo-ui/components/Table.js"></script>
|
|
126
|
+
<script type="module" src="/kempo-ui/components/tableControls/FirstPage.js"></script>
|
|
127
|
+
<script type="module" src="/kempo-ui/components/tableControls/PrevPage.js"></script>
|
|
128
|
+
<script type="module" src="/kempo-ui/components/tableControls/NextPage.js"></script>
|
|
129
|
+
<script type="module" src="/kempo-ui/components/tableControls/LastPage.js"></script>
|
|
130
|
+
<script type="module" src="/kempo-ui/components/tableControls/PageSelect.js"></script>
|
|
131
|
+
<script type="module" src="/kempo-ui/components/tableControls/PageSize.js"></script>
|
|
132
|
+
<script type="module" src="/kempo-ui/components/tableControls/Edit.js"></script>
|
|
133
|
+
<script type="module" src="/kempo-ui/components/tableControls/DeleteRecord.js"></script>
|
|
134
|
+
<script type="module" src="/kempo-ui/components/tableControls/DeleteSelected.js"></script>
|
|
135
|
+
<script type="module" src="/admin/components/TableRowLink.js"></script>
|
|
136
|
+
<script type="module" src="/kempo/components/Permission.js"></script>
|
|
137
|
+
|
|
138
|
+
<div class="d-f mb" style="align-items: center; gap: var(--spacer);">
|
|
139
|
+
<h1 class="flex mb0">Settings</h1>
|
|
140
|
+
<k-permission has="system:custom-settings:manage">
|
|
141
|
+
<button id="createSettingBtn" class="primary">Create Setting</button>
|
|
142
|
+
</k-permission>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<h2>Custom Settings</h2>
|
|
146
|
+
<k-table id="customTable" enable-pages page-size="25" page-size-options="[10,25,50,100]" placeholder="No custom settings have been created yet.">
|
|
147
|
+
<admin-table-row-link slot="before" url-pattern="{_link}" field-name="_link"></admin-table-row-link>
|
|
148
|
+
<div slot="top" class="d-f full" style="align-items: center;">
|
|
149
|
+
<div>
|
|
150
|
+
<k-permission has="system:custom-settings:manage">
|
|
151
|
+
<k-tc-delete-selected></k-tc-delete-selected>
|
|
152
|
+
</k-permission>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="d-f full" style="align-items: center;">
|
|
156
|
+
<div>
|
|
157
|
+
<k-tc-first-page></k-tc-first-page>
|
|
158
|
+
<k-tc-prev-page></k-tc-prev-page>
|
|
159
|
+
<k-tc-next-page></k-tc-next-page>
|
|
160
|
+
<k-tc-last-page></k-tc-last-page>
|
|
161
|
+
</div>
|
|
162
|
+
<div class="flex"></div>
|
|
163
|
+
<div><k-tc-page-select></k-tc-page-select></div>
|
|
164
|
+
<div class="flex"></div>
|
|
165
|
+
<div><k-tc-page-size></k-tc-page-size></div>
|
|
166
|
+
</div>
|
|
167
|
+
</k-table>
|
|
168
|
+
|
|
169
|
+
<h2>Extension Settings</h2>
|
|
170
|
+
<k-table id="extensionTable" enable-pages page-size="25" page-size-options="[10,25,50,100]" placeholder="No extension settings installed.">
|
|
171
|
+
<admin-table-row-link slot="before" url-pattern="{_link}" field-name="_link"></admin-table-row-link>
|
|
172
|
+
<div class="d-f full" style="align-items: center;">
|
|
173
|
+
<div>
|
|
174
|
+
<k-tc-first-page></k-tc-first-page>
|
|
175
|
+
<k-tc-prev-page></k-tc-prev-page>
|
|
176
|
+
<k-tc-next-page></k-tc-next-page>
|
|
177
|
+
<k-tc-last-page></k-tc-last-page>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="flex"></div>
|
|
180
|
+
<div><k-tc-page-select></k-tc-page-select></div>
|
|
181
|
+
<div class="flex"></div>
|
|
182
|
+
<div><k-tc-page-size></k-tc-page-size></div>
|
|
183
|
+
</div>
|
|
184
|
+
</k-table>
|
|
185
|
+
|
|
186
|
+
<h2>System Settings</h2>
|
|
187
|
+
<k-table id="systemTable" enable-pages page-size="25" page-size-options="[10,25,50,100]" placeholder="No system settings found.">
|
|
188
|
+
<admin-table-row-link slot="before" url-pattern="{_link}" field-name="_link"></admin-table-row-link>
|
|
189
|
+
<div class="d-f full" style="align-items: center;">
|
|
190
|
+
<div>
|
|
191
|
+
<k-tc-first-page></k-tc-first-page>
|
|
192
|
+
<k-tc-prev-page></k-tc-prev-page>
|
|
193
|
+
<k-tc-next-page></k-tc-next-page>
|
|
194
|
+
<k-tc-last-page></k-tc-last-page>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="flex"></div>
|
|
197
|
+
<div><k-tc-page-select></k-tc-page-select></div>
|
|
198
|
+
<div class="flex"></div>
|
|
199
|
+
<div><k-tc-page-size></k-tc-page-size></div>
|
|
200
|
+
</div>
|
|
201
|
+
</k-table>
|
|
202
|
+
|
|
203
|
+
<script type="module">
|
|
204
|
+
import { listSettings, setSetting, deleteSetting, getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
205
|
+
import { html } from '/kempo-ui/lit-all.min.js';
|
|
206
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
207
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
208
|
+
|
|
209
|
+
await window.customElements.whenDefined('k-table');
|
|
210
|
+
|
|
211
|
+
const [, permData] = await getAllCurrentUserPermissions();
|
|
212
|
+
const myPerms = new Set(permData?.permissions ?? []);
|
|
213
|
+
const canManageSystem = myPerms.has('system:settings:update');
|
|
214
|
+
const canManageCustom = myPerms.has('system:custom-settings:manage');
|
|
215
|
+
|
|
216
|
+
const buildFields = (editable, showOwner = false) => [
|
|
217
|
+
...(showOwner ? [{ name: 'owner', label: 'Owner', size: 120, editable: false, calculator: r => r.name.split(':')[0] }] : []),
|
|
218
|
+
{ name: 'name', label: 'Name', size: 220, editable: false, calculator: r => r.name.split(':').slice(1).join(':') },
|
|
219
|
+
{ name: 'value', label: 'Value', size: 280, editable, formatter: v => {
|
|
220
|
+
if(v === null || v === undefined) return '';
|
|
221
|
+
const s = String(v);
|
|
222
|
+
return s.length > 120 ? s.slice(0, 117) + '...' : s;
|
|
223
|
+
}},
|
|
224
|
+
{ name: 'type', label: 'Type', size: 90, editable },
|
|
225
|
+
{ name: 'isPublic', label: 'Public', size: 80, editable, formatter: v => String(v) },
|
|
226
|
+
{ name: 'description', label: 'Description', size: 320, editable },
|
|
227
|
+
{ name: 'updatedAt', label: 'Updated', size: 190, editable: false, formatter: v => new Date(v).toLocaleString() }
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
const coerceValue = (value, type) => {
|
|
231
|
+
if(type === 'number') return Number(value);
|
|
232
|
+
if(type === 'boolean') return String(value).toLowerCase() === 'true';
|
|
233
|
+
if(type === 'json') return JSON.parse(String(value));
|
|
234
|
+
return String(value);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const setupTable = ($table, owner, canEdit, loadFn) => {
|
|
238
|
+
if(canEdit){
|
|
239
|
+
$table.setAttribute('request-edit', '');
|
|
240
|
+
const edit = document.createElement('k-tc-edit');
|
|
241
|
+
edit.setAttribute('slot', 'after');
|
|
242
|
+
$table.appendChild(edit);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if(owner === 'custom' && canEdit){
|
|
246
|
+
$table.setAttribute('request-delete', '');
|
|
247
|
+
$table.setAttribute('enable-selection', '');
|
|
248
|
+
const del = document.createElement('k-tc-delete-record');
|
|
249
|
+
del.setAttribute('slot', 'after');
|
|
250
|
+
$table.appendChild(del);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
$table.addEventListener('requestSave', async ({ detail: { record, newData, approve, reject } }) => {
|
|
254
|
+
const type = newData.type ?? record.type;
|
|
255
|
+
let value = newData.value !== undefined ? newData.value : record.value;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
value = coerceValue(value, type);
|
|
259
|
+
} catch(e){
|
|
260
|
+
reject();
|
|
261
|
+
Toast.error(`Invalid ${type} value`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const isPublic = newData.isPublic !== undefined
|
|
266
|
+
? String(newData.isPublic).toLowerCase() === 'true'
|
|
267
|
+
: record.isPublic;
|
|
268
|
+
const [recordOwner, ...nameParts] = record.name.split(':');
|
|
269
|
+
const name = nameParts.join(':');
|
|
270
|
+
const [error] = await setSetting(recordOwner, name, {
|
|
271
|
+
value,
|
|
272
|
+
type,
|
|
273
|
+
isPublic,
|
|
274
|
+
description: newData.description ?? record.description
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if(error){ reject(); Toast.error(error.msg || 'Failed to update setting'); return; }
|
|
278
|
+
approve();
|
|
279
|
+
Toast.success('Setting updated');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if(owner === 'custom' && canEdit){
|
|
283
|
+
$table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
|
|
284
|
+
const displayName = r => r.name.split(':').slice(1).join(':');
|
|
285
|
+
const count = records.length;
|
|
286
|
+
const msg = count === 1
|
|
287
|
+
? `<div class="p"><p>Delete "${displayName(records[0])}"? This action cannot be undone.</p></div>`
|
|
288
|
+
: `<div class="p"><p>Delete ${count} settings? This action cannot be undone.</p></div>`;
|
|
289
|
+
Dialog.confirm(msg, async (confirmed) => {
|
|
290
|
+
if(!confirmed){ reject(); return; }
|
|
291
|
+
let failed = 0;
|
|
292
|
+
for(const record of records){
|
|
293
|
+
const [recOwner, ...nameParts] = record.name.split(':');
|
|
294
|
+
const [error] = await deleteSetting(recOwner, nameParts.join(':'));
|
|
295
|
+
if(error) failed++;
|
|
296
|
+
}
|
|
297
|
+
if(failed){
|
|
298
|
+
reject();
|
|
299
|
+
Toast.error(`Failed to delete ${failed} setting${failed !== 1 ? 's' : ''}`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
approve();
|
|
303
|
+
Toast.success(count === 1 ? 'Setting deleted' : `${count} settings deleted`);
|
|
304
|
+
loadFn();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const addLinks = settings => {
|
|
311
|
+
settings.forEach(s => {
|
|
312
|
+
const [o, ...parts] = s.name.split(':');
|
|
313
|
+
s._link = `/admin/settings/${encodeURIComponent(o)}/${encodeURIComponent(parts.join(':'))}/`;
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const loadTable = async ($table, owner, canEdit) => {
|
|
318
|
+
const [error, data] = await listSettings({ owner });
|
|
319
|
+
if(error){ Toast.error(error.msg || 'Failed to load settings'); return; }
|
|
320
|
+
addLinks(data.settings);
|
|
321
|
+
$table.setData({ records: data.settings, fields: buildFields(canEdit, owner === 'extension') });
|
|
322
|
+
$table.setupFetchRecords(data.total, async (start, count) => {
|
|
323
|
+
const [err, d] = await listSettings({ owner, limit: count, offset: start });
|
|
324
|
+
if(err) return [];
|
|
325
|
+
addLinks(d.settings);
|
|
326
|
+
return d.settings;
|
|
327
|
+
});
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const $customTable = document.getElementById('customTable');
|
|
331
|
+
const $extensionTable = document.getElementById('extensionTable');
|
|
332
|
+
const $systemTable = document.getElementById('systemTable');
|
|
333
|
+
|
|
334
|
+
setupTable($customTable, 'custom', canManageCustom, () => loadTable($customTable, 'custom', canManageCustom));
|
|
335
|
+
setupTable($extensionTable, 'extension', false, () => loadTable($extensionTable, 'extension', false));
|
|
336
|
+
setupTable($systemTable, 'system', canManageSystem, () => loadTable($systemTable, 'system', canManageSystem));
|
|
337
|
+
|
|
338
|
+
await Promise.all([
|
|
339
|
+
loadTable($customTable, 'custom', canManageCustom),
|
|
340
|
+
loadTable($extensionTable, 'extension', false),
|
|
341
|
+
loadTable($systemTable, 'system', canManageSystem)
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
if(canManageCustom){
|
|
345
|
+
document.getElementById('createSettingBtn').addEventListener('click', () => {
|
|
346
|
+
const $dialog = Dialog.create(html`
|
|
347
|
+
<div class="p">
|
|
348
|
+
<div class="mb">
|
|
349
|
+
<label class="d-b mb-sm">Name</label>
|
|
350
|
+
<input type="text" id="dlg-name" class="full" placeholder="e.g. my-setting" required>
|
|
351
|
+
</div>
|
|
352
|
+
<div class="mb">
|
|
353
|
+
<label class="d-b mb-sm">Value</label>
|
|
354
|
+
<input type="text" id="dlg-value" class="full">
|
|
355
|
+
</div>
|
|
356
|
+
<div class="mb">
|
|
357
|
+
<label class="d-b mb-sm">Type</label>
|
|
358
|
+
<select id="dlg-type" class="full">
|
|
359
|
+
<option value="string">String</option>
|
|
360
|
+
<option value="number">Number</option>
|
|
361
|
+
<option value="boolean">Boolean</option>
|
|
362
|
+
<option value="json">JSON</option>
|
|
363
|
+
</select>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="mb d-f" style="align-items: center; gap: var(--spacer-sm);">
|
|
366
|
+
<input type="checkbox" id="dlg-isPublic">
|
|
367
|
+
<label for="dlg-isPublic">Public (accessible without authentication)</label>
|
|
368
|
+
</div>
|
|
369
|
+
<div>
|
|
370
|
+
<label class="d-b mb-sm">Description</label>
|
|
371
|
+
<input type="text" id="dlg-description" class="full">
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
`, {
|
|
375
|
+
title: 'Create Setting',
|
|
376
|
+
confirmText: 'Create',
|
|
377
|
+
confirmAction: async () => {
|
|
378
|
+
const name = $dialog.querySelector('#dlg-name').value.trim();
|
|
379
|
+
if(!name){ Toast.error('Name is required'); return; }
|
|
380
|
+
if(name.includes(':')){ Toast.error('Name cannot contain ":"'); return; }
|
|
381
|
+
|
|
382
|
+
const rawValue = $dialog.querySelector('#dlg-value').value;
|
|
383
|
+
const type = $dialog.querySelector('#dlg-type').value;
|
|
384
|
+
const isPublic = $dialog.querySelector('#dlg-isPublic').checked;
|
|
385
|
+
const description = $dialog.querySelector('#dlg-description').value.trim();
|
|
386
|
+
|
|
387
|
+
let value;
|
|
388
|
+
try {
|
|
389
|
+
value = coerceValue(rawValue, type);
|
|
390
|
+
} catch(e){
|
|
391
|
+
Toast.error(`Invalid ${type} value`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const [error] = await setSetting('custom', name, { value, type, isPublic, description });
|
|
396
|
+
if(error){ Toast.error(error.msg || 'Failed to create setting'); return; }
|
|
397
|
+
Toast.success('Setting created');
|
|
398
|
+
loadTable($customTable, 'custom', canManageCustom);
|
|
399
|
+
},
|
|
400
|
+
cancelText: 'Cancel'
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
</script>
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
</k-main>
|
|
408
|
+
|
|
409
|
+
</body>
|
|
410
|
+
</html>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { dirname, join, extname } from 'path';
|
|
2
2
|
import { readFile, stat, readdir } from 'fs/promises';
|
|
3
|
-
import { pathToFileURL } from 'url';
|
|
3
|
+
import { pathToFileURL, fileURLToPath } from 'url';
|
|
4
4
|
import { renderExternalPage } from 'kempo-server/templating';
|
|
5
|
+
import getSession from '../server/utils/auth/getSession.js';
|
|
6
|
+
import currentUserHasPermission from '../server/utils/permissions/currentUserHasPermission.js';
|
|
5
7
|
import { getEnabledExtensions } from '../server/utils/extensions/scopeCache.js';
|
|
6
8
|
|
|
7
9
|
const MIME_TYPES = {
|
|
@@ -22,7 +24,9 @@ const MIME_TYPES = {
|
|
|
22
24
|
const ROUTE_FILES = ['GET.js','POST.js','PUT.js','DELETE.js','PATCH.js','HEAD.js','OPTIONS.js','index.js','CATCH.js'];
|
|
23
25
|
|
|
24
26
|
const NODE_MODULES = join(process.cwd(), 'node_modules');
|
|
25
|
-
|
|
27
|
+
|
|
28
|
+
// Resolves to dist/admin relative to this file — works both in this repo and as a consumer dependency
|
|
29
|
+
const ADMIN_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'admin');
|
|
26
30
|
|
|
27
31
|
const buildResolveDir = (base, url) => {
|
|
28
32
|
const parts = url.replace(/\/+$/, '').split('/').filter(Boolean);
|
|
@@ -65,13 +69,13 @@ const executeRouteFile = async (filePath, request, response, params = {}) => {
|
|
|
65
69
|
return true;
|
|
66
70
|
};
|
|
67
71
|
|
|
68
|
-
const serveDir = async (dirPath, method, request, response, resolveDir,
|
|
72
|
+
const serveDir = async (dirPath, method, request, response, resolveDir, rootDir, params = {}) => {
|
|
69
73
|
const candidates = [`${method}.js`, 'index.page.html', 'index.js', 'index.html', 'CATCH.js'];
|
|
70
74
|
for(const candidate of candidates){
|
|
71
75
|
const candidatePath = join(dirPath, candidate);
|
|
72
76
|
try { await stat(candidatePath); } catch { continue; }
|
|
73
77
|
if(candidate.endsWith('.page.html')){
|
|
74
|
-
const html = await renderExternalPage(candidatePath,
|
|
78
|
+
const html = await renderExternalPage(candidatePath, rootDir, resolveDir);
|
|
75
79
|
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
76
80
|
response.end(html);
|
|
77
81
|
return true;
|
|
@@ -91,20 +95,37 @@ export default config => {
|
|
|
91
95
|
const PROJECT_PUBLIC = config.rootPath || join(process.cwd(), 'public');
|
|
92
96
|
|
|
93
97
|
return async (request, response, next) => {
|
|
98
|
+
const { path } = request;
|
|
94
99
|
const url = request.url.split('?')[0];
|
|
95
100
|
|
|
96
101
|
/*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
Auth: protect /account and /admin routes
|
|
103
|
+
*/
|
|
104
|
+
if(path.startsWith('/account') || path.startsWith('/admin')){
|
|
105
|
+
const [error, session] = await getSession({ token: request.cookies.session_token });
|
|
106
|
+
if(error || !session?.user) return response.redirect('/login');
|
|
107
|
+
|
|
108
|
+
if(path.startsWith('/admin')){
|
|
109
|
+
const [permError, hasPermission] = await currentUserHasPermission(request.cookies.session_token, 'system:admin:access');
|
|
110
|
+
if(permError || !hasPermission) return response.redirect('/account');
|
|
111
|
+
|
|
112
|
+
if(path.startsWith('/admin/pages/edit')){
|
|
113
|
+
const [editErr, canEdit] = await currentUserHasPermission(request.cookies.session_token, 'system:pages:update');
|
|
114
|
+
if(editErr || !canEdit) return response.redirect('/admin/pages');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
Admin routing: /admin/** -> ADMIN_ROOT (dist/admin)
|
|
121
|
+
Uses renderExternalPage so template walk-up starts in ADMIN_ROOT, not the consumer's public/.
|
|
100
122
|
*/
|
|
101
123
|
if(url === '/admin' || url.startsWith('/admin/')){
|
|
102
|
-
const subPath = url.slice('/admin'.length) || '/';
|
|
103
|
-
const segments = subPath.replace(/^\//, '').split('/').filter(Boolean);
|
|
104
124
|
const method = request.method?.toUpperCase() || 'GET';
|
|
105
125
|
|
|
106
|
-
//
|
|
126
|
+
// Extension admin pages handled below
|
|
107
127
|
if(!url.startsWith('/admin/extension/')){
|
|
128
|
+
const segments = url.slice('/admin'.length).replace(/^\//, '').split('/').filter(Boolean);
|
|
108
129
|
const resolveDir = buildResolveDir(ADMIN_ROOT, url);
|
|
109
130
|
|
|
110
131
|
if(segments.length === 0){
|
|
@@ -139,21 +160,18 @@ export default config => {
|
|
|
139
160
|
}
|
|
140
161
|
}
|
|
141
162
|
}
|
|
142
|
-
// Not found in dist/admin — fall through to next()
|
|
143
163
|
return next();
|
|
144
164
|
}
|
|
145
165
|
}
|
|
146
166
|
|
|
147
167
|
/*
|
|
148
|
-
|
|
149
|
-
Protected by kempo-auth; no enabled check needed here.
|
|
168
|
+
Extension admin routing: /admin/extension/{name}/** -> extension's admin/ directory
|
|
150
169
|
*/
|
|
151
170
|
const adminMatch = url.match(/^\/admin\/extension\/((?:@[^/]+\/)?[^/]+)(\/.*)?$/);
|
|
152
171
|
if(adminMatch){
|
|
153
172
|
const extName = adminMatch[1];
|
|
154
173
|
const subPath = adminMatch[2] || '/';
|
|
155
|
-
const
|
|
156
|
-
const adminDir = join(extRoot, 'admin');
|
|
174
|
+
const adminDir = join(NODE_MODULES, extName, 'admin');
|
|
157
175
|
const pageCandidates = subPath.endsWith('/')
|
|
158
176
|
? [join(adminDir, subPath, 'index.page.html')]
|
|
159
177
|
: [
|
|
@@ -169,26 +187,25 @@ export default config => {
|
|
|
169
187
|
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
170
188
|
response.end(html);
|
|
171
189
|
return;
|
|
172
|
-
} catch { /* try next
|
|
190
|
+
} catch { /* try next */ }
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
const filePath = join(adminDir, subPath === '/' ? 'index.html' : subPath);
|
|
176
194
|
try {
|
|
177
|
-
const
|
|
178
|
-
if(
|
|
195
|
+
const fileStat = await stat(filePath);
|
|
196
|
+
if(fileStat.isFile()){
|
|
179
197
|
const mime = MIME_TYPES[extname(filePath)] || 'application/octet-stream';
|
|
180
198
|
response.writeHead(200, { 'Content-Type': mime });
|
|
181
199
|
response.end(await readFile(filePath));
|
|
182
200
|
return;
|
|
183
201
|
}
|
|
184
|
-
} catch { /* fall through
|
|
202
|
+
} catch { /* fall through */ }
|
|
185
203
|
|
|
186
204
|
return next();
|
|
187
205
|
}
|
|
188
206
|
|
|
189
207
|
/*
|
|
190
|
-
|
|
191
|
-
Supports: .page.html templates, JS route handlers (GET.js etc.), dynamic [param] dirs, static files.
|
|
208
|
+
Extension public scope routing: /{scope}/** -> extension's public/ directory
|
|
192
209
|
*/
|
|
193
210
|
const extensions = await getEnabledExtensions();
|
|
194
211
|
|
|
@@ -197,9 +214,7 @@ export default config => {
|
|
|
197
214
|
try {
|
|
198
215
|
pkgPath = join(NODE_MODULES, ext.name, 'package.json');
|
|
199
216
|
pkg = JSON.parse(await readFile(pkgPath, 'utf-8'));
|
|
200
|
-
} catch {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
217
|
+
} catch { continue; }
|
|
203
218
|
|
|
204
219
|
const scope = pkg.kempo?.['public-scope'];
|
|
205
220
|
if(!scope) continue;
|
|
@@ -252,4 +267,4 @@ export default config => {
|
|
|
252
267
|
|
|
253
268
|
next();
|
|
254
269
|
};
|
|
255
|
-
};
|
|
270
|
+
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fullstack CMS framework for creating websites with user authentication",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"dev": "
|
|
7
|
+
"dev": "node dev.mjs",
|
|
8
8
|
"build": "node scripts/build.js",
|
|
9
9
|
"db:generate": "drizzle-kit generate",
|
|
10
10
|
"db:migrate": "drizzle-kit migrate",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"server/db/index.js",
|
|
30
30
|
"server/db/schema.js",
|
|
31
31
|
"server/sdk.js",
|
|
32
|
-
"middleware",
|
|
32
|
+
"middleware/kempo.js",
|
|
33
33
|
"app-templates",
|
|
34
34
|
"scripts/init-db.js"
|
|
35
35
|
],
|
package/middleware/kempo-auth.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import getSession from '../server/utils/auth/getSession.js';
|
|
2
|
-
import currentUserHasPermission from '../server/utils/permissions/currentUserHasPermission.js';
|
|
3
|
-
|
|
4
|
-
export default (config) => async (request, response, next) => {
|
|
5
|
-
const { path } = request;
|
|
6
|
-
if(path.startsWith('/account') || path.startsWith('/admin')){
|
|
7
|
-
const sessionToken = request.cookies.session_token;
|
|
8
|
-
const [error, session] = await getSession({ token: sessionToken });
|
|
9
|
-
if(error || !session || !session.user){
|
|
10
|
-
return response.redirect('/login');
|
|
11
|
-
}
|
|
12
|
-
if(request.path.startsWith('/admin')){
|
|
13
|
-
const [permError, hasPermission] = await currentUserHasPermission(sessionToken, 'system:admin:access');
|
|
14
|
-
if(permError || !hasPermission){
|
|
15
|
-
return response.redirect('/account');
|
|
16
|
-
}
|
|
17
|
-
if(request.path.startsWith('/admin/pages/edit')){
|
|
18
|
-
const [editPermError, hasEditPermission] = await currentUserHasPermission(sessionToken, 'system:pages:update');
|
|
19
|
-
if(editPermError || !hasEditPermission){
|
|
20
|
-
return response.redirect('/admin/pages');
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return next();
|
|
26
|
-
};
|