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.
Files changed (111) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +364 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1009 -161
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/server/routes/api/analytics.js +5 -1
  107. package/server/routes/api/contacts.js +42 -0
  108. package/server/routes/api/forms.js +8 -0
  109. package/server/routes/api/seo.js +4 -1
  110. package/server/routes/api/todo.js +50 -10
  111. 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: SIDEBAR_URL}})); } catch { /* no sidebar */ }
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 => (r === 'all' || (r === 'resets' ? isReset(e) : !isReset(e) && (r === 'ok') === Boolean(e.ok)))
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 h = await api(`/health${refresh ? '?refresh=1' : ''}`);
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
- if (here() && r) signins.value = r.entries;
246
- } catch (e) { E.toast(`Could not load sign-ins: ${e.message || e}`, {type: 'error'}); }
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
- scope(M.effect(() => { void days.value; if (tab.peek() === 'signins') loadSignins(); }));
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
- if (await E.confirm(`${esc(e.message)}<br><br>Block it anyway? You would need the command line to get back in.`)) return blockRule(rule, note, true);
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
- function wireList(el, items, main) {
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
- async function openSettings() {
380
- if (settingsOpen) return;
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
- const f = {
393
- lockOn: M.observable(s.lockout.enabled), acc: M.observable(String(s.lockout.accountThreshold)), addr: M.observable(String(s.lockout.addressThreshold)),
394
- win: M.observable(String(s.lockout.windowMin)), base: M.observable(String(s.lockout.baseMin)), max: M.observable(String(s.lockout.maxMin)),
395
- 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),
396
- 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)),
397
- extra: M.observable(s.probes.extra.join('\n')), allow: M.observable(s.probes.allow.join('\n')),
398
- signinDays: M.observable(String(s.signinDays)), staleDays: M.observable(String(s.staleAdminDays)),
399
- nLock: M.observable(s.notify.lockouts), nAfter: M.observable(s.notify.successAfterFails), nProbe: M.observable(s.notify.probeBlocks), nReset: M.observable(s.notify.resetProbing)
400
- };
401
- const body = () => ({
402
- 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},
403
- policy: {enabled: f.polOn.value, minLength: f.minLen.value, blockCommon: f.common.value, blockPersonal: f.personal.value},
404
- 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},
405
- signinDays: f.signinDays.value, staleAdminDays: f.staleDays.value,
406
- notify: {lockouts: f.nLock.value, successAfterFails: f.nAfter.value, probeBlocks: f.nProbe.value, resetProbing: f.nReset.value}
407
- });
408
- const opened = JSON.stringify(body());
409
- const saving = M.observable(false);
410
- const checked = M.computed(() => checkSettings(body(), s));
411
- const errText = M.computed(() => Object.values(checked.value.errors).join(' '));
412
- const model = {...f, saving, errText, cannot: M.computed(() => saving.value || Boolean(errText.value)), checksOk};
413
- 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>
414
- <input id="scx-s-${id}" type="number" min="${min}" max="${max}" class="form-input" data-model="${key}.value"></div>`;
415
- 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>`;
416
- const so = E.slideover({title: 'Security settings', size: 'md', position: 'right'});
417
- const panel = fragment(`
418
- <div class="scx-form">
419
- <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>
420
- <section><h4>Sign-in lockout</h4><div class="scx-form-grid">
421
- ${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.')}
422
- ${num('acc', 'acc', 'Wrong passwords on one account', 'How many, within the time below, lock the account.', 3, 50)}
423
- ${num('addr', 'addr', 'Wrong passwords from one address', 'Higher than for an account: a whole office can share one address.', 5, 500)}
424
- ${num('win', 'win', 'Counted over (minutes)', '', 1, 1440)}
425
- ${num('base', 'base', 'First lock (minutes)', 'Each lock after that is twice as long.', 1, 60)}
426
- ${num('max', 'max', 'Longest lock (minutes)', '', 1, 10080)}
427
- </div></section>
428
- <section><h4>Password rules</h4><div class="scx-form-grid">
429
- ${sw('polOn', 'Check new passwords', 'Applies when a password is set or changed - accounts keep the one they have until then.')}
430
- ${num('minlen', 'minLen', 'Shortest password', 'Length matters most. 10 or more is sensible; 12+ better.', 8, 64)}
431
- ${sw('common', 'Refuse the 10,000 most common passwords')}
432
- ${sw('personal', 'Refuse passwords containing the person\'s name or email')}
433
- </div></section>
434
- <section><h4>Scanners</h4><div class="scx-form-grid">
435
- ${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.')}
436
- ${num('after', 'after', 'Probes before a block', '', 1, 100)}
437
- ${num('within', 'within', 'Counted over (minutes)', '', 1, 1440)}
438
- ${num('formin', 'forMin', 'Block for (minutes)', '', 1, 10080)}
439
- <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>
440
- <textarea id="scx-s-extra" rows="2" class="form-input scx-mono" data-model="extra.value" placeholder="/old-admin/"></textarea></div>
441
- <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>
442
- <textarea id="scx-s-allow" rows="2" class="form-input scx-mono" data-model="allow.value"></textarea></div>
443
- </div></section>
444
- <section><h4>Keeping and telling</h4><div class="scx-form-grid">
445
- ${num('days', 'signinDays', 'Keep the sign-in log (days)', '', 7, 365)}
446
- ${num('stale', 'staleDays', 'Admin unused after (days)', 'The health check flags admins who have not signed in for this long.', 14, 730)}
447
- ${sw('nLock', 'Notify when an account or address is locked')}
448
- ${sw('nAfter', 'Notify when someone signs in after many wrong passwords')}
449
- ${sw('nProbe', 'Notify when a scanner is blocked', 'Off by default: on a public site this happens daily.')}
450
- ${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.')}
451
- </div></section>
452
- <section><h4>Locked out yourself?</h4>
453
- <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>
454
- <div class="scx-code">node plugins/security/bin/unlock.js --list\nnode plugins/security/bin/unlock.js --all</div>
455
- </section>
456
- <p class="scx-err" data-bind-hidden="!errText.value" data-bind-text="errText.value"></p>
457
- <div class="scx-actions">
458
- <small class="scx-muted">Ctrl+Enter saves.</small><span class="scx-spacer"></span>
459
- <button type="button" class="btn btn-sm btn-ghost" data-on-click="cancel">Cancel</button>
460
- <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>
461
- </div>
462
- </div>`);
463
- async function save() {
464
- if (model.cannot.peek()) return;
465
- saving.value = true;
466
- try {
467
- const r = await api('/settings', {method: 'PUT', body: JSON.stringify(checked.peek().settings)});
468
- settings.value = r.settings;
469
- E.toast('Settings saved.', {type: 'success'});
470
- so.close(true);
471
- loadAll(true);
472
- } catch (e) {
473
- E.toast(`Not saved: ${e.message || e}`, {type: 'error'});
474
- } finally {
475
- saving.value = false;
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()) I.scan(root);
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
  };