domma-cms 1.6.56 → 1.6.57
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/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
|
@@ -14,6 +14,7 @@ import {fmtDate, fmtDateTime, fmtTime} from '/plugins/_lib/admin/ui/dates.js';
|
|
|
14
14
|
import {resetRowText} from '../lib/resets.js';
|
|
15
15
|
import {checkSettings, DEFAULT_SETTINGS, mergeDefaults} from '../lib/settings.js';
|
|
16
16
|
import {ipRuleValid} from '../lib/ip.js';
|
|
17
|
+
import {applySettingsChanges, draftKey, draftNote, draftValues, fingerprint, settingsChanges, signinCounts, signinInResult, signinMatches, stableJson, usableDraft, withCount} from '../lib/screen-state.js';
|
|
17
18
|
|
|
18
19
|
const API = '/plugins/security';
|
|
19
20
|
const ROUTE = /^#\/plugins\/security(\/[a-z]+)?\/?(\?.*)?$/;
|
|
@@ -25,17 +26,20 @@ const REASONS = {password: 'Wrong password', unknown: 'No such account', inactiv
|
|
|
25
26
|
let kit = null;
|
|
26
27
|
async function loadKit() {
|
|
27
28
|
if (kit) return kit;
|
|
28
|
-
const [api, chrome, help, tk] = await Promise.all([
|
|
29
|
+
const [api, chrome, help, tk, loading] = await Promise.all([
|
|
29
30
|
import('/admin/js/api.js'),
|
|
30
31
|
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
31
32
|
import('/admin/js/lib/help-popover.js').catch(() => ({})),
|
|
32
|
-
import('/admin/js/lib/tool-kit.js')
|
|
33
|
+
import('/admin/js/lib/tool-kit.js'),
|
|
34
|
+
import('/admin/js/lib/loading.js').catch(() => null) // skeletons (core 1.x); older cores keep the slot's text
|
|
33
35
|
]);
|
|
34
36
|
kit = {
|
|
35
37
|
apiRequest: api.apiRequest,
|
|
36
38
|
addBannerAction: chrome.addBannerAction || (() => null),
|
|
37
39
|
helpPopovers: help.helpPopovers || (() => () => {}),
|
|
38
|
-
onceClosed: tk.onceClosed, openMenuAt: tk.openMenuAt, viewLifetime: tk.viewLifetime
|
|
40
|
+
onceClosed: tk.onceClosed, openMenuAt: tk.openMenuAt, viewLifetime: tk.viewLifetime,
|
|
41
|
+
/** A skeleton in `slot` while `promise` runs (data back once it has gone); just the promise without one. */
|
|
42
|
+
withSkeleton: (slot, promise) => (slot && loading?.whileLoading ? loading.whileLoading(slot, promise, loading.SHAPES?.rows) : promise)
|
|
39
43
|
};
|
|
40
44
|
return kit;
|
|
41
45
|
}
|
|
@@ -50,9 +54,18 @@ async function copy(text, what) {
|
|
|
50
54
|
try { await navigator.clipboard.writeText(text); E.toast(`Copied ${what}.`, {type: 'success'}); }
|
|
51
55
|
catch { E.toast(`Could not copy - ${text}`, {type: 'warning', duration: 6000}); }
|
|
52
56
|
}
|
|
53
|
-
function refreshBadge() {
|
|
54
|
-
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url
|
|
57
|
+
function refreshBadge(url = SIDEBAR_URL) {
|
|
58
|
+
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
|
|
55
59
|
}
|
|
60
|
+
/** The signed-in user's id (drafts are per person), '' when unknown. */
|
|
61
|
+
function userId() {
|
|
62
|
+
try { return String(S.get('auth_user')?.id || ''); } catch { return ''; }
|
|
63
|
+
}
|
|
64
|
+
const store = {
|
|
65
|
+
get(key) { try { return key ? S.get(key) : null; } catch { return null; } },
|
|
66
|
+
set(key, v) { try { if (key) S.set(key, v); } catch { /* storage blocked */ } },
|
|
67
|
+
remove(key) { try { if (key) { if (S.remove) S.remove(key); else S.set(key, null); } } catch { /* storage blocked */ } }
|
|
68
|
+
};
|
|
56
69
|
const when = (iso) => {
|
|
57
70
|
const d = new Date(iso);
|
|
58
71
|
const today = new Date().toDateString() === d.toDateString();
|
|
@@ -129,6 +142,10 @@ export const securityView = {
|
|
|
129
142
|
const fromHash = location.hash.match(/security\/([a-z]+)/)?.[1];
|
|
130
143
|
const tab = M.observable(TABS.includes(fromHash) ? fromHash : 'overview');
|
|
131
144
|
const loading = M.observable(true);
|
|
145
|
+
// First loads: a skeleton in the list's slot, its empty note hidden; reloads keep the list on screen.
|
|
146
|
+
const checksLoading = M.observable(true);
|
|
147
|
+
const signinsLoading = M.observable(false);
|
|
148
|
+
const blocksLoading = M.observable(true);
|
|
132
149
|
const error = M.observable('');
|
|
133
150
|
const health = M.observable(null);
|
|
134
151
|
const overview = M.observable(null);
|
|
@@ -189,9 +206,7 @@ export const securityView = {
|
|
|
189
206
|
const only = ipFilter.value;
|
|
190
207
|
// A reset request is in the log too (kind 'reset'): only under All and Resets.
|
|
191
208
|
const isReset = (e) => e.kind === 'reset';
|
|
192
|
-
signinList = signins.value.filter(e => (
|
|
193
|
-
&& (!only || e.ip === only)
|
|
194
|
-
&& (!q || [e.email, e.ip, e.ua, e.reason].some(v => String(v || '').toLowerCase().includes(q))));
|
|
209
|
+
signinList = signins.value.filter(e => signinInResult(e, r) && signinMatches(e, {query: q, ip: only}));
|
|
195
210
|
signinRows.value = signinList.slice(0, 1000).map((e, i) => {
|
|
196
211
|
const row = {id: `s${i}`, when: when(e.at), email: e.email || '—', ip: e.ip || '—', ua: e.ua || ''};
|
|
197
212
|
if (isReset(e)) {
|
|
@@ -208,6 +223,12 @@ export const securityView = {
|
|
|
208
223
|
if (result.value === 'resets') return `${n.toLocaleString('en-GB')} reset request${n === 1 ? '' : 's'}${f}`;
|
|
209
224
|
return `${n.toLocaleString('en-GB')} sign-in${n === 1 ? '' : 's'}${f}`;
|
|
210
225
|
});
|
|
226
|
+
// The count on each result button: what the search and the address filter leave in the period loaded.
|
|
227
|
+
const resCounts = M.computed(() => signinCounts(signins.value, {query: query.value, ip: ipFilter.value}));
|
|
228
|
+
const resLabel = {
|
|
229
|
+
all: M.computed(() => withCount('All', resCounts.value.all)), failed: M.computed(() => withCount('Failed', resCounts.value.failed)),
|
|
230
|
+
ok: M.computed(() => withCount('Succeeded', resCounts.value.ok)), resets: M.computed(() => withCount('Resets', resCounts.value.resets))
|
|
231
|
+
};
|
|
211
232
|
const signinEmpty = M.computed(() => (query.value.trim() || ipFilter.value ? 'No sign-ins match.' : 'No sign-ins in this period.'));
|
|
212
233
|
|
|
213
234
|
const lockRows = M.observable([]);
|
|
@@ -224,11 +245,14 @@ export const securityView = {
|
|
|
224
245
|
// ---- Server ----
|
|
225
246
|
async function loadHealth(refresh = false) {
|
|
226
247
|
try {
|
|
227
|
-
const
|
|
248
|
+
const first = checksLoading.peek();
|
|
249
|
+
const h = await k.withSkeleton(first ? root.querySelector('.scx-checks-loading') : null, api(`/health${refresh ? '?refresh=1' : ''}`));
|
|
228
250
|
if (here() && h) { health.value = h; error.value = ''; }
|
|
229
251
|
if (refresh) refreshBadge();
|
|
230
252
|
} catch (e) {
|
|
231
253
|
if (here()) error.value = /403|forbidden/i.test(String(e.message)) ? 'You do not have access to Security.' : `Could not run the checks: ${e.message || e}`;
|
|
254
|
+
} finally {
|
|
255
|
+
if (here()) checksLoading.value = false;
|
|
232
256
|
}
|
|
233
257
|
}
|
|
234
258
|
async function loadOverview() {
|
|
@@ -239,31 +263,46 @@ export const securityView = {
|
|
|
239
263
|
checksOk.value = o.checks !== false;
|
|
240
264
|
} catch { /* the health error says enough */ }
|
|
241
265
|
}
|
|
266
|
+
let signinsLoaded = false;
|
|
267
|
+
let signinSeq = 0;
|
|
242
268
|
async function loadSignins() {
|
|
269
|
+
const seq = ++signinSeq;
|
|
270
|
+
const first = !signinsLoaded;
|
|
271
|
+
if (first) signinsLoading.value = true;
|
|
243
272
|
try {
|
|
244
|
-
const r = await api(`/signins?days=${encodeURIComponent(days.peek())}`);
|
|
245
|
-
|
|
246
|
-
|
|
273
|
+
const r = await k.withSkeleton(first ? root.querySelector('.scx-signins-loading') : null, api(`/signins?days=${encodeURIComponent(days.peek())}`));
|
|
274
|
+
// A later period asked for meanwhile wins; the screen may have gone.
|
|
275
|
+
if (!here() || seq !== signinSeq) return;
|
|
276
|
+
if (r) { signins.value = r.entries; signinsLoaded = true; }
|
|
277
|
+
} catch (e) {
|
|
278
|
+
if (here()) E.toast(`Could not load sign-ins: ${e.message || e}`, {type: 'error'});
|
|
279
|
+
} finally {
|
|
280
|
+
if (here() && seq === signinSeq) signinsLoading.value = false;
|
|
281
|
+
}
|
|
247
282
|
}
|
|
248
283
|
async function loadBlocks() {
|
|
284
|
+
const first = blocksLoading.peek();
|
|
249
285
|
try {
|
|
250
|
-
const r = await api('/blocks');
|
|
286
|
+
const r = await k.withSkeleton(first ? root.querySelector('.scx-blocks-loading') : null, api('/blocks'));
|
|
251
287
|
if (here() && r) blocks.value = r;
|
|
252
288
|
} catch { /* shown empty */ }
|
|
289
|
+
finally { if (here()) blocksLoading.value = false; }
|
|
253
290
|
}
|
|
254
291
|
async function loadAll(refresh = false) {
|
|
255
292
|
loading.value = true;
|
|
256
293
|
await Promise.all([loadHealth(refresh), loadOverview(), loadBlocks(), tab.peek() === 'signins' ? loadSignins() : null]);
|
|
257
294
|
if (here()) loading.value = false;
|
|
258
295
|
}
|
|
259
|
-
|
|
296
|
+
// A new period reloads the log (the first run is the mount's own load).
|
|
297
|
+
let daysSeen = days.peek();
|
|
298
|
+
scope(M.effect(() => { const d = days.value; if (d === daysSeen) return; daysSeen = d; if (tab.peek() === 'signins') loadSignins(); }));
|
|
260
299
|
|
|
261
300
|
// ---- Actions ----
|
|
262
301
|
async function act(fn, done) {
|
|
263
302
|
try {
|
|
264
303
|
await fn();
|
|
265
304
|
if (done) E.toast(done, {type: 'success'});
|
|
266
|
-
await Promise.all([loadBlocks(), loadOverview()]);
|
|
305
|
+
if (here()) await Promise.all([loadBlocks(), loadOverview()]);
|
|
267
306
|
} catch (e) {
|
|
268
307
|
if (/403|forbidden/i.test(String(e.message))) { canManage.value = false; E.toast('Only people who may manage Security can change this.', {type: 'warning'}); }
|
|
269
308
|
else E.toast(`Not done: ${e.message || e}`, {type: 'error'});
|
|
@@ -276,11 +315,13 @@ export const securityView = {
|
|
|
276
315
|
try {
|
|
277
316
|
await api('/blocks', {method: 'POST', body: JSON.stringify({rule, note, confirmSelf})});
|
|
278
317
|
E.toast(`${rule} is blocked.`, {type: 'success'});
|
|
279
|
-
await Promise.all([loadBlocks(), loadOverview()]);
|
|
318
|
+
if (here()) await Promise.all([loadBlocks(), loadOverview()]);
|
|
280
319
|
return true;
|
|
281
320
|
} catch (e) {
|
|
282
321
|
if (e.status === 409 && !confirmSelf) {
|
|
283
|
-
|
|
322
|
+
const anyway = await E.confirm(`${esc(e.message)}<br><br>Block it anyway? You would need the command line to get back in.`);
|
|
323
|
+
// Left while asked: nothing is blocked behind the person's back.
|
|
324
|
+
if (anyway && here()) return blockRule(rule, note, true);
|
|
284
325
|
return false;
|
|
285
326
|
}
|
|
286
327
|
E.toast(`Not blocked: ${e.message || e}`, {type: 'error'});
|
|
@@ -326,7 +367,7 @@ export const securityView = {
|
|
|
326
367
|
{label: ipFilter.peek() === e.ip ? 'Show every address' : 'Only this address', icon: 'filter', disabled: !e.ip, action: () => { ipFilter.value = ipFilter.peek() === e.ip ? '' : e.ip; }},
|
|
327
368
|
{label: 'Unlock this account', icon: 'unlock', disabled: !e.email || !canManage.peek(), action: () => unlockKey(`a:${e.email.toLowerCase()}`, e.email)},
|
|
328
369
|
{label: 'Block this address…', icon: 'x-circle', disabled: !e.ip || !canManage.peek(), action: async () => {
|
|
329
|
-
if (await E.confirm(`Block ${esc(e.ip)}? Nothing from it will reach the site.`)) blockRule(e.ip, `From the sign-in log (${e.email || 'unknown'})`);
|
|
370
|
+
if (await E.confirm(`Block ${esc(e.ip)}? Nothing from it will reach the site.`) && here()) blockRule(e.ip, `From the sign-in log (${e.email || 'unknown'})`);
|
|
330
371
|
}},
|
|
331
372
|
{type: 'divider'},
|
|
332
373
|
{label: 'Copy the address', icon: 'copy', disabled: !e.ip, action: () => copy(e.ip, 'the address')},
|
|
@@ -350,17 +391,23 @@ export const securityView = {
|
|
|
350
391
|
const r = blocks.peek().rules[rowIndex(row)];
|
|
351
392
|
if (!r) return [];
|
|
352
393
|
return [{header: r.rule}, {label: 'Remove the block', icon: 'trash-2', danger: true, disabled: !canManage.peek(), action: async () => {
|
|
353
|
-
if (await E.confirm(`Let ${esc(r.rule)} reach the site again?`)) removeRule(r.rule);
|
|
394
|
+
if (await E.confirm(`Let ${esc(r.rule)} reach the site again?`) && here()) removeRule(r.rule);
|
|
354
395
|
}}, {type: 'divider'}, {label: 'Copy', icon: 'copy', action: () => copy(r.rule, 'the rule')}];
|
|
355
396
|
}
|
|
356
397
|
|
|
357
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Right-click, ⋮, Shift+F10 and Enter on a row; a click does `main`, which
|
|
400
|
+
* for a row with no one obvious action is its menu (not while text is being
|
|
401
|
+
* selected - an address or email is often copied by hand).
|
|
402
|
+
*/
|
|
403
|
+
function wireList(el, items, main = (row) => k.openMenuAt(row)) {
|
|
358
404
|
const menu = E.contextMenu(el, {cursor: 'glyph', match: '[data-row]', inherit: false, density: 'compact', items: (row) => items(row)});
|
|
359
405
|
scope(() => menu?.destroy?.());
|
|
360
406
|
const onClick = (e) => {
|
|
361
407
|
const row = e.target.closest('[data-row]');
|
|
362
408
|
if (!row || e.target.closest('a')) return;
|
|
363
409
|
if (e.target.closest('.scx-kebab')) { k.openMenuAt(row); return; }
|
|
410
|
+
if (String(window.getSelection?.() || '')) return;
|
|
364
411
|
main?.(row);
|
|
365
412
|
};
|
|
366
413
|
const onKey = (e) => {
|
|
@@ -375,126 +422,182 @@ export const securityView = {
|
|
|
375
422
|
}
|
|
376
423
|
|
|
377
424
|
// ---- Settings (slideover) ----
|
|
425
|
+
// Unsaved settings are kept per person when the screen is left (never secrets: there are none here),
|
|
426
|
+
// with `was` - the stored settings' fingerprint when editing began - so a stale draft says so.
|
|
427
|
+
const DRAFT_KEY = draftKey(userId());
|
|
378
428
|
let settingsOpen = false;
|
|
379
|
-
|
|
380
|
-
|
|
429
|
+
/** The open panel: {take(draft)} puts a picked-up draft into it. */
|
|
430
|
+
let openPanel = null;
|
|
431
|
+
/** The panel being opened (its settings still loading), so a draft offered meanwhile can wait for it. */
|
|
432
|
+
let opening = null;
|
|
433
|
+
/** @param {{draft?: {values: object, was: string}}} [opts] - a picked-up draft to put over the stored settings */
|
|
434
|
+
function openSettings(opts = {}) {
|
|
435
|
+
if (settingsOpen) return Promise.resolve(false);
|
|
436
|
+
opening = openSettingsNow(opts).finally(() => { opening = null; });
|
|
437
|
+
return opening;
|
|
438
|
+
}
|
|
439
|
+
async function openSettingsNow({draft = null} = {}) {
|
|
381
440
|
settingsOpen = true;
|
|
382
441
|
let s;
|
|
383
442
|
try {
|
|
384
443
|
const r = await api('/settings');
|
|
444
|
+
// Left while it loaded: no panel over another screen.
|
|
445
|
+
if (!here()) { settingsOpen = false; return; }
|
|
385
446
|
s = r.settings;
|
|
386
447
|
checksOk.value = r.checks !== false;
|
|
387
448
|
} catch (e) {
|
|
449
|
+
// Nothing to save over: no form opens on a failed load.
|
|
388
450
|
settingsOpen = false;
|
|
389
|
-
E.toast(`Could not load the settings: ${e.message || e}`, {type: 'error'});
|
|
451
|
+
if (here()) E.toast(`Could not load the settings: ${e.message || e}`, {type: 'error'});
|
|
390
452
|
return;
|
|
391
453
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
454
|
+
try {
|
|
455
|
+
const f = {
|
|
456
|
+
lockOn: M.observable(s.lockout.enabled), acc: M.observable(String(s.lockout.accountThreshold)), addr: M.observable(String(s.lockout.addressThreshold)),
|
|
457
|
+
win: M.observable(String(s.lockout.windowMin)), base: M.observable(String(s.lockout.baseMin)), max: M.observable(String(s.lockout.maxMin)),
|
|
458
|
+
polOn: M.observable(s.policy.enabled), minLen: M.observable(String(s.policy.minLength)), common: M.observable(s.policy.blockCommon), personal: M.observable(s.policy.blockPersonal),
|
|
459
|
+
probeOn: M.observable(s.probes.enabled), after: M.observable(String(s.probes.blockAfter)), within: M.observable(String(s.probes.withinMin)), forMin: M.observable(String(s.probes.blockForMin)),
|
|
460
|
+
extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
|
|
461
|
+
signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
|
|
462
|
+
nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
|
|
463
|
+
};
|
|
464
|
+
const body = () => ({
|
|
465
|
+
lockout: {enabled: f.lockOn.value, accountThreshold: f.acc.value, addressThreshold: f.addr.value, windowMin: f.win.value, baseMin: f.base.value, maxMin: f.max.value},
|
|
466
|
+
policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
|
|
467
|
+
probes: {enabled: f.probeOn.value, blockAfter: f.after.value, withinMin: f.within.value, blockForMin: f.forMin.value, extra: f.extra.value, allow: f.allow.value},
|
|
468
|
+
signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
|
|
469
|
+
notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
|
|
470
|
+
});
|
|
471
|
+
const opened = JSON.stringify(body());
|
|
472
|
+
// The draft's age: a picked-up draft keeps the one it started from.
|
|
473
|
+
let was = draft?.was || fingerprint(s);
|
|
474
|
+
const keys = Object.keys(f);
|
|
475
|
+
const read = () => Object.fromEntries(keys.map(key => [key, f[key].peek()]));
|
|
476
|
+
const write = (values) => {
|
|
477
|
+
for (const [key, v] of Object.entries(draftValues(values, keys))) f[key].value = typeof f[key].peek() === 'boolean' ? v === true || v === 'true' : String(v ?? '');
|
|
478
|
+
};
|
|
479
|
+
// The form as the stored settings fill it: a draft keeps only what differs from this.
|
|
480
|
+
const base = read();
|
|
481
|
+
const changes = () => settingsChanges(read(), base);
|
|
482
|
+
// A picked-up draft's changes go over the settings as they are NOW - the rest stays.
|
|
483
|
+
if (draft) write(applySettingsChanges(base, draft.changes));
|
|
484
|
+
const dirty = () => JSON.stringify(body()) !== opened;
|
|
485
|
+
const keepDraft = () => {
|
|
486
|
+
const c = changes();
|
|
487
|
+
if (dirty() && Object.keys(c).length) store.set(DRAFT_KEY, {changes: c, was, at: new Date().toISOString()});
|
|
488
|
+
};
|
|
489
|
+
const saving = M.observable(false);
|
|
490
|
+
const checked = M.computed(() => checkSettings(body(), s));
|
|
491
|
+
const errText = M.computed(() => Object.values(checked.value.errors).join(' '));
|
|
492
|
+
const model = {...f, saving, errText, cannot: M.computed(() => saving.value || Boolean(errText.value)), checksOk};
|
|
493
|
+
const num = (id, key, label, help, min, max) => `<div><label class="form-label" for="scx-s-${id}">${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>
|
|
494
|
+
<input id="scx-s-${id}" type="number" min="${min}" max="${max}" class="form-input" data-model="${key}.value"></div>`;
|
|
495
|
+
const sw = (key, label, help = '') => `<label class="scx-wide" style="display:flex;align-items:center;gap:.5rem"><input type="checkbox" class="form-switch" data-model="${key}.value"> ${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>`;
|
|
496
|
+
const so = E.slideover({title: 'Security settings', size: 'md', position: 'right'});
|
|
497
|
+
const panel = fragment(`
|
|
498
|
+
<div class="scx-form">
|
|
499
|
+
<p class="scx-note is-warn" data-bind-hidden="checksOk.value">This version of Domma CMS cannot run sign-in checks: the lockout and password rules below take effect once it is updated (0.83 or later).</p>
|
|
500
|
+
<section><h4>Sign-in lockout</h4><div class="scx-form-grid">
|
|
501
|
+
${sw('lockOn', 'Lock after repeated wrong passwords', 'Locks the account and the address for a while - longer each time. The right password does not get in while it is locked, so guessing stops paying.')}
|
|
502
|
+
${num('acc', 'acc', 'Wrong passwords on one account', 'How many, within the time below, lock the account.', 3, 50)}
|
|
503
|
+
${num('addr', 'addr', 'Wrong passwords from one address', 'Higher than for an account: a whole office can share one address.', 5, 500)}
|
|
504
|
+
${num('win', 'win', 'Counted over (minutes)', '', 1, 1440)}
|
|
505
|
+
${num('base', 'base', 'First lock (minutes)', 'Each lock after that is twice as long.', 1, 60)}
|
|
506
|
+
${num('max', 'max', 'Longest lock (minutes)', '', 1, 10080)}
|
|
507
|
+
</div></section>
|
|
508
|
+
<section><h4>Password rules</h4><div class="scx-form-grid">
|
|
509
|
+
${sw('polOn', 'Check new passwords', 'Applies when a password is set or changed - accounts keep the one they have until then.')}
|
|
510
|
+
${num('minlen', 'minLen', 'Shortest password', 'Length matters most. 10 or more is sensible; 12+ better.', 8, 64)}
|
|
511
|
+
${sw('common', 'Refuse the 10,000 most common passwords')}
|
|
512
|
+
${sw('personal', 'Refuse passwords containing the person\'s name or email')}
|
|
513
|
+
</div></section>
|
|
514
|
+
<section><h4>Scanners</h4><div class="scx-form-grid">
|
|
515
|
+
${sw('probeOn', 'Refuse probes, and block addresses that keep probing', 'Requests for things no Domma site has (WordPress, .env files, database dumps) get an empty not found. An address someone signed in from in the last week is never blocked.')}
|
|
516
|
+
${num('after', 'after', 'Probes before a block', '', 1, 100)}
|
|
517
|
+
${num('within', 'within', 'Counted over (minutes)', '', 1, 1440)}
|
|
518
|
+
${num('formin', 'forMin', 'Block for (minutes)', '', 1, 10080)}
|
|
519
|
+
<div class="scx-wide"><label class="form-label" for="scx-s-extra">Also treat as probes <span data-help="One path per line, starting with /. A trailing / covers everything under it."></span></label>
|
|
520
|
+
<textarea id="scx-s-extra" rows="2" class="form-input scx-mono" data-model="extra.value" placeholder="/old-admin/"></textarea></div>
|
|
521
|
+
<div class="scx-wide"><label class="form-label" for="scx-s-allow">This site does serve <span data-help="Paths that look like probes but are real here - for example /legacy/ if you host old .php pages. One per line."></span></label>
|
|
522
|
+
<textarea id="scx-s-allow" rows="2" class="form-input scx-mono" data-model="allow.value"></textarea></div>
|
|
523
|
+
</div></section>
|
|
524
|
+
<section><h4>Keeping and telling</h4><div class="scx-form-grid">
|
|
525
|
+
${num('days', 'signinDays', 'Keep the sign-in log (days)', '', 7, 365)}
|
|
526
|
+
${num('stale', 'staleDays', 'Admin unused after (days)', 'The health check flags admins who have not signed in for this long.', 14, 730)}
|
|
527
|
+
${sw('nLock', 'Notify when an account or address is locked')}
|
|
528
|
+
${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
|
|
529
|
+
${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
|
|
530
|
+
${sw('nReset', 'Notify when password resets look like someone trying addresses', 'One address asking about 3 different accounts within an hour (a person only asks about their own), or 10 requests in an hour for accounts that do not exist, from anywhere. Every request is in the sign-in log either way.')}
|
|
531
|
+
</div></section>
|
|
532
|
+
<section><h4>Locked out yourself?</h4>
|
|
533
|
+
<p class="scx-note">Run this in the site's folder on the server - it clears lockouts and blocks, and the site notices within seconds:</p>
|
|
534
|
+
<div class="scx-code">node plugins/security/bin/unlock.js --list\nnode plugins/security/bin/unlock.js --all</div>
|
|
535
|
+
</section>
|
|
536
|
+
<p class="scx-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
|
|
537
|
+
<div class="scx-actions">
|
|
538
|
+
<small class="scx-muted">Ctrl+Enter saves.</small><span class="scx-spacer"></span>
|
|
539
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
|
|
540
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value"><span data-icon="save" data-icon-size="13"></span> Save</button>
|
|
541
|
+
</div>
|
|
542
|
+
</div>`);
|
|
543
|
+
async function save() {
|
|
544
|
+
if (model.cannot.peek() || saving.peek()) return;
|
|
545
|
+
saving.value = true;
|
|
546
|
+
// Locked while it saves: the panel closes after, so anything typed meanwhile would be lost unsaid.
|
|
547
|
+
panel.inert = true;
|
|
548
|
+
const sent = changes();
|
|
549
|
+
try {
|
|
550
|
+
const r = await api('/settings', {method: 'PUT', body: JSON.stringify(checked.peek().settings)});
|
|
551
|
+
// Kept as a draft if the screen went meanwhile - it is saved now, so that draft goes (only if it is this one).
|
|
552
|
+
if (stableJson(store.get(DRAFT_KEY)?.changes) === stableJson(sent)) store.remove(DRAFT_KEY);
|
|
553
|
+
if (!here()) return;
|
|
554
|
+
settings.value = r.settings;
|
|
555
|
+
E.toast('Settings saved.', {type: 'success'});
|
|
556
|
+
so.close(true);
|
|
557
|
+
loadAll(true);
|
|
558
|
+
} catch (e) {
|
|
559
|
+
if (here()) E.toast(`Not saved: ${e.message || e}`, {type: 'error'});
|
|
560
|
+
} finally {
|
|
561
|
+
saving.value = false;
|
|
562
|
+
panel.inert = false;
|
|
563
|
+
}
|
|
476
564
|
}
|
|
565
|
+
so.element.appendChild(panel);
|
|
566
|
+
const disposeHelp = k.helpPopovers(panel);
|
|
567
|
+
const handle = M.applyBindings(model, panel, {methods: {save, cancel: () => so.close()}});
|
|
568
|
+
panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
|
|
569
|
+
// Reloading or closing the tab: keep the draft, and let the browser ask.
|
|
570
|
+
const onUnload = (e) => { if (!dirty()) return; keepDraft(); e.preventDefault(); e.returnValue = ''; };
|
|
571
|
+
window.addEventListener('beforeunload', onUnload);
|
|
572
|
+
let releaseLifetime = () => {};
|
|
573
|
+
k.onceClosed(so, () => { handle.dispose(); disposeHelp(); settingsOpen = false; openPanel = null; window.removeEventListener('beforeunload', onUnload); releaseLifetime(); });
|
|
574
|
+
openPanel = {take(d) { write(applySettingsChanges(read(), d.changes)); if (d.was) was = d.was; }};
|
|
575
|
+
// The discard question wraps AFTER onceClosed, so Cancel leaves the panel (and its bindings) alive.
|
|
576
|
+
const close = so.close.bind(so);
|
|
577
|
+
let asking = false;
|
|
578
|
+
so.close = async (force) => {
|
|
579
|
+
// Never while saving (Escape, Cancel, the backdrop): the panel closes itself once the PUT answers.
|
|
580
|
+
if (force !== true && saving.peek()) return undefined;
|
|
581
|
+
if (force !== true && dirty()) {
|
|
582
|
+
if (asking) return undefined;
|
|
583
|
+
asking = true;
|
|
584
|
+
const ok = await E.confirm('Discard the changes you have not saved?');
|
|
585
|
+
asking = false;
|
|
586
|
+
if (!ok || !settingsOpen) return undefined;
|
|
587
|
+
}
|
|
588
|
+
return close();
|
|
589
|
+
};
|
|
590
|
+
// The screen left with the panel open: the changes stay as a draft for next time.
|
|
591
|
+
releaseLifetime = lifetime.add(() => { keepDraft(); try { so.close(true); } catch { /* gone */ } });
|
|
592
|
+
I.scan(panel);
|
|
593
|
+
so.open();
|
|
594
|
+
return true;
|
|
595
|
+
} catch (e) {
|
|
596
|
+
// Opening failed part-way: release the once-only claim, or the cog would never open it again.
|
|
597
|
+
settingsOpen = false;
|
|
598
|
+
E.toast(`Could not open the settings: ${e?.message || e}`, {type: 'error'});
|
|
599
|
+
return false;
|
|
477
600
|
}
|
|
478
|
-
so.element.appendChild(panel);
|
|
479
|
-
const disposeHelp = k.helpPopovers(panel);
|
|
480
|
-
const handle = M.applyBindings(model, panel, {methods: {save, cancel: () => so.close()}});
|
|
481
|
-
panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
|
|
482
|
-
k.onceClosed(so, () => { handle.dispose(); disposeHelp(); settingsOpen = false; });
|
|
483
|
-
const close = so.close.bind(so);
|
|
484
|
-
let asking = false;
|
|
485
|
-
so.close = async (force) => {
|
|
486
|
-
if (force !== true && JSON.stringify(body()) !== opened) {
|
|
487
|
-
if (asking) return undefined;
|
|
488
|
-
asking = true;
|
|
489
|
-
const ok = await E.confirm('Discard the changes you have not saved?');
|
|
490
|
-
asking = false;
|
|
491
|
-
if (!ok) return undefined;
|
|
492
|
-
}
|
|
493
|
-
return close();
|
|
494
|
-
};
|
|
495
|
-
scope(() => { try { so.close(true); } catch { /* gone */ } });
|
|
496
|
-
I.scan(panel);
|
|
497
|
-
so.open();
|
|
498
601
|
}
|
|
499
602
|
|
|
500
603
|
// ---- Bindings ----
|
|
@@ -509,13 +612,14 @@ export const securityView = {
|
|
|
509
612
|
if (t === 'blocked') loadBlocks();
|
|
510
613
|
};
|
|
511
614
|
const model = {tab, loading, error, checksOk, canManage, scoreText, scoreWidth, scoreTone, attentionText, asOf, ov, blockedCount, checks,
|
|
512
|
-
result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText
|
|
615
|
+
result, days, query, signinRows, signinText, signinEmpty, lockRows, tempRows, ruleRows, newRule, newNote, youText,
|
|
616
|
+
checksLoading, signinsLoading, blocksLoading, resAll: resLabel.all, resFailed: resLabel.failed, resOk: resLabel.ok, resResets: resLabel.resets};
|
|
513
617
|
const methods = {
|
|
514
618
|
res_resets: () => { result.value = 'resets'; },
|
|
515
619
|
tab_overview: () => setTab('overview'), tab_signins: () => setTab('signins'), tab_blocked: () => setTab('blocked'),
|
|
516
620
|
res_all: () => { result.value = 'all'; }, res_failed: () => { result.value = 'failed'; }, res_ok: () => { result.value = 'ok'; },
|
|
517
621
|
exportCsv,
|
|
518
|
-
unlockAll: async () => { if (await E.confirm('Unlock every locked account and address?')) act(() => api('/lockouts?key=*', {method: 'DELETE'}), 'Everything unlocked.'); },
|
|
622
|
+
unlockAll: async () => { if (await E.confirm('Unlock every locked account and address?') && here()) act(() => api('/lockouts?key=*', {method: 'DELETE'}), 'Everything unlocked.'); },
|
|
519
623
|
addRule: () => {
|
|
520
624
|
const rule = newRule.peek().trim();
|
|
521
625
|
if (!ipRuleValid(rule)) { E.toast('Not an address or range - for example 81.2.69.142, 192.168.1.* or 10.0.0.0/8.', {type: 'warning'}); return false; }
|
|
@@ -523,20 +627,54 @@ export const securityView = {
|
|
|
523
627
|
return false;
|
|
524
628
|
}
|
|
525
629
|
};
|
|
630
|
+
// A half-typed block (an address, a reason - nothing secret) waits for the next visit.
|
|
631
|
+
const RULE_KEY = DRAFT_KEY ? `security.rule-draft.${userId()}` : '';
|
|
632
|
+
const ruleDraft = store.get(RULE_KEY);
|
|
633
|
+
if (ruleDraft && typeof ruleDraft === 'object') { newRule.value = String(ruleDraft.rule || '').slice(0, 200); newNote.value = String(ruleDraft.note || '').slice(0, 200); }
|
|
634
|
+
scope(() => {
|
|
635
|
+
const rule = newRule.peek().trim();
|
|
636
|
+
const note = newNote.peek().trim();
|
|
637
|
+
if (rule || note) store.set(RULE_KEY, {rule, note}); else store.remove(RULE_KEY);
|
|
638
|
+
});
|
|
526
639
|
const bindings = M.applyBindings(model, root, {methods});
|
|
527
640
|
scope(() => bindings.dispose());
|
|
528
|
-
scope(k.helpPopovers(root, {position: 'bottom'}));
|
|
529
641
|
wireList(root.querySelector('.scx-checks'), checkItems, (row) => { const c = checkList[rowIndex(row)]; if (c) toggleCheck(c); });
|
|
530
642
|
wireList(root.querySelector('.scx-signins'), signinItems);
|
|
531
643
|
wireList(root.querySelector('.scx-locks'), lockItems);
|
|
532
644
|
wireList(root.querySelector('.scx-temps'), tempItems);
|
|
533
645
|
wireList(root.querySelector('.scx-rules'), ruleItems);
|
|
646
|
+
// Help on the toolbars and card titles, once the rows and bindings are in.
|
|
647
|
+
scope(k.helpPopovers(root, {position: 'bottom'}));
|
|
534
648
|
|
|
535
649
|
k.addBannerAction($container, {id: 'security-refresh', icon: 'refresh-cw', title: 'Run the checks again', onClick: () => loadAll(true)});
|
|
536
|
-
k.addBannerAction($container, {id: 'security-settings', icon: 'settings', title: 'Security settings', onClick: openSettings});
|
|
650
|
+
k.addBannerAction($container, {id: 'security-settings', icon: 'settings', title: 'Security settings', onClick: () => openSettings()});
|
|
537
651
|
|
|
538
652
|
I.scan(root);
|
|
539
653
|
await loadAll(false);
|
|
540
|
-
if (here())
|
|
654
|
+
if (!here()) return;
|
|
655
|
+
I.scan(root);
|
|
656
|
+
await offerDraft();
|
|
657
|
+
|
|
658
|
+
/** Settings left unsaved last time: offer them back, saying if the stored settings moved on since. */
|
|
659
|
+
async function offerDraft() {
|
|
660
|
+
const draft = usableDraft(store.get(DRAFT_KEY));
|
|
661
|
+
if (!draft) return;
|
|
662
|
+
let now = '';
|
|
663
|
+
try { now = fingerprint((await api('/settings')).settings); } catch { return; } // asked next time
|
|
664
|
+
if (!here()) return;
|
|
665
|
+
const note = draftNote(draft, now);
|
|
666
|
+
// OK is the destructive choice: Cancel, Escape and a backdrop click all answer false, and all KEEP.
|
|
667
|
+
const throwAway = await E.confirm(`Throw away the changes to the Security settings you left unsaved? ${draft.full ? 'Keep them to carry on where you left off.' : 'Keeping them puts back only what you changed.'}${note ? `<br><br>${esc(note)}` : ''}`,
|
|
668
|
+
{title: 'Unsaved settings', confirmText: 'Throw away', cancelText: 'Keep them'});
|
|
669
|
+
// Left while asked: the draft stays for next time.
|
|
670
|
+
if (!here()) return;
|
|
671
|
+
if (throwAway) { store.remove(DRAFT_KEY); return; }
|
|
672
|
+
// Kept: the panel holds it now (and keeps it again if the screen is left); it leaves storage only once it is open.
|
|
673
|
+
// The cog may have opened the panel while this was asked: the draft goes into that one.
|
|
674
|
+
if (opening) await opening;
|
|
675
|
+
if (!here()) return;
|
|
676
|
+
if (openPanel) { openPanel.take(draft); store.remove(DRAFT_KEY); return; }
|
|
677
|
+
if (await openSettings({draft})) store.remove(DRAFT_KEY);
|
|
678
|
+
}
|
|
541
679
|
}
|
|
542
680
|
};
|