domma-cms 1.8.5 → 1.8.7
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/CHANGELOG.md +10 -0
- package/CLAUDE.md +20 -1
- package/admin/js/lib/menu-editor-arrange.js +2 -2
- package/admin/js/lib/shortcode-registry.js +2 -2
- package/admin/js/lib/wordpress-import-arrange.js +1 -1
- package/admin/js/templates/docs/usage-editions.html +0 -1
- package/admin/js/templates/menu-editor.html +6 -0
- package/admin/js/templates/wordpress-import.html +23 -1
- package/admin/js/views/menu-editor.js +5 -5
- package/admin/js/views/wordpress-import.js +30 -7
- package/config/cache.json.example +0 -4
- package/docs/DommaFeatures.md +1 -1
- package/docs/cache.md +3 -3
- package/docs/editions-and-licences.md +0 -3
- package/docs/plugin-development.md +1 -1
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/mobile-ui.mjs +1 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/wordpress-import.js +81 -4
- package/server/server.js +1 -6
- package/server/services/cache/index.js +3 -11
- package/server/services/markdown.js +62 -24
- package/server/services/menus.js +21 -0
- package/server/services/passwordReset.js +71 -3
- package/server/services/renderer.js +2 -0
- package/server/services/wpImport/index.js +183 -20
- package/server/services/wpImport/people.js +202 -0
- package/server/services/wpImport/plan.js +13 -2
- package/server/services/wpImport/wxr.js +5 -2
- package/server/templates/page.html +2 -2
|
@@ -57,6 +57,37 @@ marked.setOptions({
|
|
|
57
57
|
// Collection shortcode helpers
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Make text from a collection entry inert to the shortcode stages that run
|
|
62
|
+
* after `[collection]` / `[view]`.
|
|
63
|
+
*
|
|
64
|
+
* Entry values are DATA - public form submissions among them - and the
|
|
65
|
+
* rendered display is spliced into the page before tabs, form, embed, badge,
|
|
66
|
+
* button, card, slideover and the rest run, so a stored `[form id="x" /]`
|
|
67
|
+
* would otherwise be executed on the public page. Applied to the HTML that
|
|
68
|
+
* carries entry values (escaped text, attribute values, a rendered Markdown
|
|
69
|
+
* body, a `{{{raw}}}` placeholder), never to authored markup: a block
|
|
70
|
+
* template's own shortcodes still run.
|
|
71
|
+
*
|
|
72
|
+
* Every shortcode pattern starts `[name`, `[/name` or `[ name`, so the
|
|
73
|
+
* character after each `[` is written as a numeric entity (`[form` becomes
|
|
74
|
+
* `[form`) and a trailing `[` as `[`. Nothing can match, yet the
|
|
75
|
+
* brackets themselves stay, so a Markdown link in a value that a block
|
|
76
|
+
* template leaves to marked (`[text](url)`) is still a link. Entities read
|
|
77
|
+
* back as the same characters in text and attribute values, and the final
|
|
78
|
+
* sanitiser decodes them, so nothing visible changes; the base64 `data-ctx`
|
|
79
|
+
* / table payloads carry no brackets and are left as is, so filter, search
|
|
80
|
+
* and export see the stored values.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} html - already-escaped HTML or text
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function inertShortcodes(html) {
|
|
86
|
+
return String(html ?? '')
|
|
87
|
+
.replace(/\[([\w\s\/-])/g, (_, c) => `[&#${c.codePointAt(0)};`)
|
|
88
|
+
.replace(/\[$/, '[');
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
function escapeHtmlText(str) {
|
|
61
92
|
return String(str ?? '')
|
|
62
93
|
.replace(/&/g, '&')
|
|
@@ -349,7 +380,9 @@ function interpolateBlockTemplate(blockTemplate, e) {
|
|
|
349
380
|
// One pass: a raw value put in (pre-rendered HTML) is never read again for
|
|
350
381
|
// placeholders - text that shows "{{title}}" keeps it.
|
|
351
382
|
return blockTemplate.replace(/\{\{\{(\w+)\}\}\}|\{\{([\w_]+)(?::([^{}]+))?\}\}/g, (_, raw, key, fmt) => {
|
|
352
|
-
|
|
383
|
+
// Entry values never carry a shortcode into the page (inertShortcodes);
|
|
384
|
+
// the template's own markup is untouched.
|
|
385
|
+
if (raw !== undefined) return inertShortcodes(String(e.data?.[raw] ?? ''));
|
|
353
386
|
let value;
|
|
354
387
|
if (key === '_id') value = e.id ?? '';
|
|
355
388
|
else if (key === '_createdAt') value = e.meta?.createdAt ?? '';
|
|
@@ -357,9 +390,9 @@ function interpolateBlockTemplate(blockTemplate, e) {
|
|
|
357
390
|
else value = e.data?.[key] ?? '';
|
|
358
391
|
if (fmt) {
|
|
359
392
|
const formatted = formatDate(value, fmt);
|
|
360
|
-
if (formatted !== null) return escapeHtmlText(formatted);
|
|
393
|
+
if (formatted !== null) return inertShortcodes(escapeHtmlText(formatted));
|
|
361
394
|
}
|
|
362
|
-
return escapeHtmlText(value);
|
|
395
|
+
return inertShortcodes(escapeHtmlText(value));
|
|
363
396
|
});
|
|
364
397
|
}
|
|
365
398
|
|
|
@@ -401,14 +434,14 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
|
|
|
401
434
|
|
|
402
435
|
const items = entries.map(e => {
|
|
403
436
|
const entryCls = entryClassSuffix(ctxOpts).trim();
|
|
404
|
-
const wrapOpen = `<div${entryCls ? ` class="${entryCls}"` : ''}${blockAttr} data-entry-id="${escapeAttr(String(e.id ?? ''))}">`;
|
|
437
|
+
const wrapOpen = `<div${entryCls ? ` class="${entryCls}"` : ''}${blockAttr} data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">`;
|
|
405
438
|
const wrapClose = `</div>`;
|
|
406
439
|
let html = interpolateBlockTemplate(blockTemplate, e);
|
|
407
440
|
|
|
408
441
|
if (ctaOpts) {
|
|
409
442
|
const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
|
|
410
443
|
let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
|
|
411
|
-
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
|
|
444
|
+
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${inertShortcodes(escapeAttr(e.id || ''))}"`;
|
|
412
445
|
if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
|
|
413
446
|
if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
|
|
414
447
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
@@ -636,15 +669,16 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
|
|
|
636
669
|
if (ctaOpts) {
|
|
637
670
|
const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
|
|
638
671
|
let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
|
|
639
|
-
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
|
|
672
|
+
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${inertShortcodes(escapeAttr(e.id || ''))}"`;
|
|
640
673
|
if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
|
|
641
674
|
if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
|
|
642
675
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
643
676
|
footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
|
|
644
677
|
}
|
|
645
|
-
return `<div class="card${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || ' '}</div>${footer}</div>`;
|
|
678
|
+
return `<div class="card${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || ' '}</div>${footer}</div>`;
|
|
646
679
|
}).join('\n');
|
|
647
|
-
|
|
680
|
+
// No authored markup in this display - everything bracketed in it is entry data.
|
|
681
|
+
return inertShortcodes(`<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`);
|
|
648
682
|
}
|
|
649
683
|
|
|
650
684
|
function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts, ctxOpts = {}) {
|
|
@@ -664,15 +698,16 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
664
698
|
if (ctaOpts) {
|
|
665
699
|
const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
|
|
666
700
|
let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
|
|
667
|
-
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
|
|
701
|
+
let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${inertShortcodes(escapeAttr(e.id || ''))}"`;
|
|
668
702
|
if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
|
|
669
703
|
if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
|
|
670
704
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
671
705
|
ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
|
|
672
706
|
}
|
|
673
|
-
return `<div class="dm-collection-list-item${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
|
|
707
|
+
return `<div class="dm-collection-list-item${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
|
|
674
708
|
}).join('\n');
|
|
675
|
-
|
|
709
|
+
// No authored markup in this display - everything bracketed in it is entry data.
|
|
710
|
+
return inertShortcodes(`<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`);
|
|
676
711
|
}
|
|
677
712
|
|
|
678
713
|
/**
|
|
@@ -695,7 +730,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
695
730
|
const bodyVal = String(e.data?.[bodyField] ?? '');
|
|
696
731
|
const bodyHtml = marked.parse(bodyVal);
|
|
697
732
|
return (
|
|
698
|
-
`<div class="accordion-item${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">\n` +
|
|
733
|
+
`<div class="accordion-item${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">\n` +
|
|
699
734
|
` <h3 class="accordion-header"><button class="accordion-button" type="button">${title}` +
|
|
700
735
|
`<span class="accordion-icon" data-icon="chevron-down"></span></button></h3>\n` +
|
|
701
736
|
` <div class="accordion-body"><div class="accordion-content">${bodyHtml}</div></div>\n` +
|
|
@@ -704,7 +739,8 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
704
739
|
}).join('\n');
|
|
705
740
|
|
|
706
741
|
const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries, ctxOpts);
|
|
707
|
-
|
|
742
|
+
// No authored markup in this display - everything bracketed in it is entry data.
|
|
743
|
+
return inertShortcodes(`<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`);
|
|
708
744
|
}
|
|
709
745
|
|
|
710
746
|
/**
|
|
@@ -740,7 +776,7 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
|
|
|
740
776
|
const bodyHtml = bodyField
|
|
741
777
|
? marked.parse(String(e.data?.[bodyField] ?? ''))
|
|
742
778
|
: '';
|
|
743
|
-
return `<div class="dm-progression-item${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
|
|
779
|
+
return `<div class="dm-progression-item${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
|
|
744
780
|
}).join('\n');
|
|
745
781
|
|
|
746
782
|
const ctx = buildCtxAttr(
|
|
@@ -748,7 +784,8 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
|
|
|
748
784
|
entries,
|
|
749
785
|
ctxOpts
|
|
750
786
|
);
|
|
751
|
-
|
|
787
|
+
// No authored markup in this display - everything bracketed in it is entry data.
|
|
788
|
+
return inertShortcodes(`<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`);
|
|
752
789
|
}
|
|
753
790
|
|
|
754
791
|
/**
|
|
@@ -779,7 +816,7 @@ function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
|
|
|
779
816
|
}
|
|
780
817
|
|
|
781
818
|
const passthrough = ['autoplay', 'interval', 'loop', 'animation', 'animation-duration', 'animation-easing', 'arrow-position',
|
|
782
|
-
'background', 'arrow-background', 'arrow-color', 'height-mode', 'height']
|
|
819
|
+
'background', 'arrow-background', 'arrow-color', 'height-mode', 'height', 'mobile-popup']
|
|
783
820
|
.map(k => dataAttrs[k] ? ` data-${k}="${escapeAttr(String(dataAttrs[k]))}"` : '')
|
|
784
821
|
.join('');
|
|
785
822
|
|
|
@@ -799,9 +836,9 @@ function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
|
|
|
799
836
|
const bodyHtml = bodyField && e.data?.[bodyField] != null
|
|
800
837
|
? `<div class="carousel-slide-description">${marked.parse(String(e.data[bodyField]))}</div>`
|
|
801
838
|
: '';
|
|
802
|
-
inner = img + (title || bodyHtml ? `<div class="carousel-slide-content">${title}${bodyHtml}</div>` : '');
|
|
839
|
+
inner = inertShortcodes(img + (title || bodyHtml ? `<div class="carousel-slide-content">${title}${bodyHtml}</div>` : ''));
|
|
803
840
|
}
|
|
804
|
-
return `<div class="carousel-slide${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
|
|
841
|
+
return `<div class="carousel-slide${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${inner}</div>`;
|
|
805
842
|
}).join('\n');
|
|
806
843
|
|
|
807
844
|
const ctxFields = blockTemplate
|
|
@@ -892,9 +929,9 @@ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
|
|
|
892
929
|
const body = bodyField && e.data?.[bodyField] != null
|
|
893
930
|
? `${title ? ' ' : ''}${marked.parseInline(String(e.data[bodyField]))}`
|
|
894
931
|
: '';
|
|
895
|
-
inner = (title + body) || escapeHtmlText(String(e.id ?? ''));
|
|
932
|
+
inner = inertShortcodes((title + body) || escapeHtmlText(String(e.id ?? '')));
|
|
896
933
|
}
|
|
897
|
-
return `<div class="${itemClassStr}${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
|
|
934
|
+
return `<div class="${itemClassStr}${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${inner}</div>`;
|
|
898
935
|
}).join('\n');
|
|
899
936
|
|
|
900
937
|
const ctxFields = blockTemplate
|
|
@@ -954,7 +991,7 @@ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
|
|
|
954
991
|
// browsing happens without the shortcode itself having to learn joins.
|
|
955
992
|
if (isInteractiveCollection(attrs)) {
|
|
956
993
|
const shell = await renderViewBrowserShell(attrs, {viewConfig, allowed, user});
|
|
957
|
-
result = result.replace(fullMatch, shell);
|
|
994
|
+
result = result.replace(fullMatch, () => shell);
|
|
958
995
|
continue;
|
|
959
996
|
}
|
|
960
997
|
|
|
@@ -1097,7 +1134,7 @@ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
|
|
|
1097
1134
|
replacement = `<div class="dm-view-wrapper${cls}"${id}>${replacement}</div>`;
|
|
1098
1135
|
}
|
|
1099
1136
|
|
|
1100
|
-
result = result.replace(fullMatch, replacement);
|
|
1137
|
+
result = result.replace(fullMatch, () => replacement);
|
|
1101
1138
|
}
|
|
1102
1139
|
|
|
1103
1140
|
return restore(result);
|
|
@@ -1154,12 +1191,12 @@ async function processCollectionBlocks(markdown, tagSet) {
|
|
|
1154
1191
|
// browser hydrates and replaces it).
|
|
1155
1192
|
if (isInteractiveCollection(attrs)) {
|
|
1156
1193
|
const shell = await renderCollectionBrowserShell(attrs);
|
|
1157
|
-
result = result.replace(fullMatch, shell);
|
|
1194
|
+
result = result.replace(fullMatch, () => shell);
|
|
1158
1195
|
continue;
|
|
1159
1196
|
}
|
|
1160
1197
|
|
|
1161
1198
|
const replacement = await renderCollectionFragment(attrs);
|
|
1162
|
-
result = result.replace(fullMatch, replacement);
|
|
1199
|
+
result = result.replace(fullMatch, () => replacement);
|
|
1163
1200
|
}
|
|
1164
1201
|
|
|
1165
1202
|
return restore(result);
|
|
@@ -3160,6 +3197,7 @@ function processCarouselBlocks(markdown) {
|
|
|
3160
3197
|
(_, attrStr, body) => {
|
|
3161
3198
|
const attrs = parseShortcodeAttrs(attrStr);
|
|
3162
3199
|
const dataAttrs = [
|
|
3200
|
+
attrs['mobile-popup'] === 'true' ? ' data-mobile-popup="true"' : '',
|
|
3163
3201
|
attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
|
|
3164
3202
|
attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
|
|
3165
3203
|
attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
|
package/server/services/menus.js
CHANGED
|
@@ -284,6 +284,14 @@ export function normaliseDock(dock) {
|
|
|
284
284
|
return Object.keys(out).length ? out : null;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** Mobile navbar choices are independent and opt in. */
|
|
288
|
+
export function normaliseMobile(mobile) {
|
|
289
|
+
if (!mobile || typeof mobile !== 'object' || Array.isArray(mobile)) return null;
|
|
290
|
+
const out = Object.fromEntries(['hamburgerOnly', 'hideBrand']
|
|
291
|
+
.filter(key => mobile[key] === true).map(key => [key, true]));
|
|
292
|
+
return Object.keys(out).length ? out : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
287
295
|
// ---------------------------------------------------------------------------
|
|
288
296
|
// Per-page overrides - the `[navbar ... /]` tag.
|
|
289
297
|
//
|
|
@@ -610,6 +618,7 @@ export async function createMenu(input) {
|
|
|
610
618
|
...(input.class != null && input.class !== '' && {class: input.class}),
|
|
611
619
|
...(input.style != null && {style: input.style}),
|
|
612
620
|
...(normaliseDock(input.dock) && {dock: normaliseDock(input.dock)}),
|
|
621
|
+
...(normaliseMobile(input.mobile) && {mobile: normaliseMobile(input.mobile)}),
|
|
613
622
|
...(binding != null && {binding}),
|
|
614
623
|
items: Array.isArray(input.items) ? input.items : [],
|
|
615
624
|
meta: {
|
|
@@ -658,6 +667,7 @@ export async function updateMenu(slug, input) {
|
|
|
658
667
|
...(input.class != null && input.class !== '' && {class: input.class}),
|
|
659
668
|
...(input.style != null && {style: input.style}),
|
|
660
669
|
...(normaliseDock(input.dock) && {dock: normaliseDock(input.dock)}),
|
|
670
|
+
...(normaliseMobile(input.mobile) && {mobile: normaliseMobile(input.mobile)}),
|
|
661
671
|
...(binding != null && {binding}),
|
|
662
672
|
items: Array.isArray(input.items) ? input.items : [],
|
|
663
673
|
meta: {
|
|
@@ -864,6 +874,17 @@ export function validateMenu(menu) {
|
|
|
864
874
|
if (menu.align != null && menu.align !== '' && !MENU_ALIGN.includes(menu.align)) {
|
|
865
875
|
errors.push(`align must be one of: ${MENU_ALIGN.join(', ')}`);
|
|
866
876
|
}
|
|
877
|
+
if (menu.mobile != null) {
|
|
878
|
+
if (typeof menu.mobile !== 'object' || Array.isArray(menu.mobile)) {
|
|
879
|
+
errors.push('mobile must be an object');
|
|
880
|
+
} else {
|
|
881
|
+
for (const key of ['hamburgerOnly', 'hideBrand']) {
|
|
882
|
+
if (menu.mobile[key] != null && typeof menu.mobile[key] !== 'boolean') {
|
|
883
|
+
errors.push(`mobile.${key} must be true or false`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
867
888
|
if (menu.highlight != null) {
|
|
868
889
|
if (typeof menu.highlight !== 'object' || Array.isArray(menu.highlight)) {
|
|
869
890
|
errors.push('highlight must be an object');
|
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
* - an administrator's reset (Users › ⋮ › Send password reset / Copy a
|
|
29
29
|
* reset link) - adminReset(); the admin's own request is signed in, so its
|
|
30
30
|
* origin is trusted as it stands;
|
|
31
|
+
* - an invitation (adminInvite) - "an account has been made for you, set your
|
|
32
|
+
* password": the same token store, link and set-password screen as a reset,
|
|
33
|
+
* but the link lives INVITE_LIFETIME (7 days), not resetTokenExpiry. The
|
|
34
|
+
* expiry is stored with the token, so the screen and the reset route check
|
|
35
|
+
* either the same way; whichever link was issued last replaces the other,
|
|
36
|
+
* and the reset route clears it on use - one token per account, once;
|
|
31
37
|
* - checkResetToken() for the reset screen, which checks the link on
|
|
32
38
|
* opening rather than after a new password has been typed twice;
|
|
33
39
|
* - the "your password was changed" email on every auth:passwordChanged
|
|
@@ -80,6 +86,9 @@ export function durationText(ms) {
|
|
|
80
86
|
return `${n} minute${n === 1 ? '' : 's'}`;
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
/** How long an invitation's set-your-password link lives - fixed, whatever resetTokenExpiry says. */
|
|
90
|
+
export const INVITE_LIFETIME = {ms: 7 * 86_400_000, text: '7 days'};
|
|
91
|
+
|
|
83
92
|
/** How long a reset link lives, from config/auth.json `resetTokenExpiry`. */
|
|
84
93
|
export function resetLifetime() {
|
|
85
94
|
const ms = durationMs(getConfig('auth')?.resetTokenExpiry || '1h');
|
|
@@ -328,15 +337,17 @@ function fromOf(site) {
|
|
|
328
337
|
}
|
|
329
338
|
|
|
330
339
|
/**
|
|
331
|
-
* A new reset token for a user; replaces any earlier one.
|
|
340
|
+
* A new reset token for a user; replaces any earlier one (a reset's or an invitation's).
|
|
332
341
|
*
|
|
333
342
|
* @param {string} userId
|
|
343
|
+
* @param {{ms?: number}} [o] - its lifetime; a reset's (resetTokenExpiry) unless given
|
|
334
344
|
* @returns {Promise<{token: string, expiresAt: string}>}
|
|
335
345
|
*/
|
|
336
|
-
export async function issueResetToken(userId) {
|
|
346
|
+
export async function issueResetToken(userId, {ms} = {}) {
|
|
337
347
|
const token = crypto.randomBytes(32).toString('hex');
|
|
338
348
|
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
|
339
|
-
const
|
|
349
|
+
const life = Number.isFinite(ms) && ms > 0 ? ms : resetLifetime().ms;
|
|
350
|
+
const expiresAt = new Date(Date.now() + life).toISOString();
|
|
340
351
|
await setResetToken(userId, hash, expiresAt);
|
|
341
352
|
return {token, expiresAt};
|
|
342
353
|
}
|
|
@@ -444,6 +455,63 @@ export async function adminReset({user, origin, via, by}) {
|
|
|
444
455
|
return {sent: true, expiresAt, expiresIn};
|
|
445
456
|
}
|
|
446
457
|
|
|
458
|
+
/**
|
|
459
|
+
* The invitation email: an account was made for them, choose a password.
|
|
460
|
+
*
|
|
461
|
+
* @param {{name: string, email: string, link: string, lifetime: string, siteTitle: string, invitedBy?: string}} o
|
|
462
|
+
* @returns {{subject: string, html: string, text: string}}
|
|
463
|
+
*/
|
|
464
|
+
export function buildInviteEmail({name, email: address, link, lifetime, siteTitle, invitedBy = ''}) {
|
|
465
|
+
const site = siteTitle || 'the site';
|
|
466
|
+
const hi = name ? `Hi ${name},` : 'Hello,';
|
|
467
|
+
const who = invitedBy ? `${invitedBy}, an administrator of ${site},` : `An administrator of ${site}`;
|
|
468
|
+
const line = `An account has been made for you on ${site}${address ? ` (you sign in with ${address})` : ''}. ${who} invites you to set your password and sign in.`;
|
|
469
|
+
return {
|
|
470
|
+
subject: `You have an account on ${site} - set your password`,
|
|
471
|
+
html: `
|
|
472
|
+
<!DOCTYPE html>
|
|
473
|
+
<html>
|
|
474
|
+
<body style="font-family:sans-serif;max-width:560px;margin:0 auto;padding:24px;">
|
|
475
|
+
<h2 style="color:#333;">Your account on ${esc(site)}</h2>
|
|
476
|
+
<p>${esc(hi)}</p>
|
|
477
|
+
<p>${esc(line)}</p>
|
|
478
|
+
<p style="margin:24px 0;">
|
|
479
|
+
<a href="${esc(link)}" style="background:#5b8cff;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;display:inline-block;">Set your password</a>
|
|
480
|
+
</p>
|
|
481
|
+
<p style="color:#888;font-size:.85rem;">The link works once and expires in ${esc(lifetime)}. Not expecting this? Ignore it - nobody can sign in to the account until a password is set.</p>
|
|
482
|
+
<p style="color:#bbb;font-size:.8rem;">${esc(link)}</p>
|
|
483
|
+
</body>
|
|
484
|
+
</html>`.trim(),
|
|
485
|
+
text: `${hi}\n\n${line}\n\nSet your password here (the link works once and expires in ${lifetime}):\n${link}\n\n`
|
|
486
|
+
+ 'Not expecting this? Ignore it - nobody can sign in to the account until a password is set.'
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* An invitation to an account an administrator (or an import) made: a
|
|
492
|
+
* set-your-password link valid INVITE_LIFETIME, emailed. It replaces any
|
|
493
|
+
* earlier link for the account. The caller has already decided this account
|
|
494
|
+
* may be invited.
|
|
495
|
+
*
|
|
496
|
+
* @param {{user: object, origin: string, by?: {id?: string, name?: string, email?: string}|true}} o
|
|
497
|
+
* @returns {Promise<{sent: true, expiresAt: string, expiresIn: string}>}
|
|
498
|
+
* @throws {Error} code 'NO_SMTP' | 'INACTIVE' | 'NO_ORIGIN'
|
|
499
|
+
*/
|
|
500
|
+
export async function adminInvite({user, origin, by}) {
|
|
501
|
+
const fail = (code, message) => Object.assign(new Error(message), {code});
|
|
502
|
+
if (!user.isActive) throw fail('INACTIVE', 'This account is inactive - make it active first, or it cannot sign in.');
|
|
503
|
+
const site = getConfig('site') || {};
|
|
504
|
+
if (!mailer.isSmtpConfigured(site.smtp)) throw fail('NO_SMTP', 'No mail server is set up (Settings › Email), so the invitation cannot be emailed.');
|
|
505
|
+
if (!origin) throw fail('NO_ORIGIN', 'The site does not know its own address. Set the Site URL in Settings › General.');
|
|
506
|
+
const {token, expiresAt} = await issueResetToken(user.id, {ms: INVITE_LIFETIME.ms});
|
|
507
|
+
hooks.emit('auth:resetIssued', {userId: user.id, by: by?.id ?? null, via: 'invite'});
|
|
508
|
+
const message = buildInviteEmail({name: user.name, email: user.email, link: resetLink(origin, token), lifetime: INVITE_LIFETIME.text,
|
|
509
|
+
siteTitle: site.title, invitedBy: by === true ? '' : (by?.name || by?.email || '')});
|
|
510
|
+
const transport = await mailer.createTransport(site.smtp);
|
|
511
|
+
await mailer.sendEmail(transport, {...fromOf(site), to: user.email, ...message});
|
|
512
|
+
return {sent: true, expiresAt, expiresIn: INVITE_LIFETIME.text};
|
|
513
|
+
}
|
|
514
|
+
|
|
447
515
|
// ---------------------------------------------------------------------------
|
|
448
516
|
// The reset screen
|
|
449
517
|
// ---------------------------------------------------------------------------
|
|
@@ -305,6 +305,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
305
305
|
// Docked behaviour (rail width, collapse, autohide, hide on scroll) - site.js
|
|
306
306
|
// wires it; width and autohide also shape the body below, from the first paint.
|
|
307
307
|
...(navMenu.dock && {dock: navMenu.dock}),
|
|
308
|
+
...(navMenu.mobile && {mobile: navMenu.mobile}),
|
|
308
309
|
// Item highlighting: the navbar is built in the browser, so the
|
|
309
310
|
// config has to travel to site.js rather than being rendered here.
|
|
310
311
|
...(navMenu.highlight && {highlight: navMenu.highlight}),
|
|
@@ -1076,6 +1077,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
1076
1077
|
// Docked behaviour (rail width, collapse, autohide, hide on scroll) - site.js
|
|
1077
1078
|
// wires it; width and autohide also shape the body below, from the first paint.
|
|
1078
1079
|
...(navMenu.dock && {dock: navMenu.dock}),
|
|
1080
|
+
...(navMenu.mobile && {mobile: navMenu.mobile}),
|
|
1079
1081
|
// Item highlighting: the navbar is built in the browser, so the
|
|
1080
1082
|
// config has to travel to site.js rather than being rendered here.
|
|
1081
1083
|
...(navMenu.highlight && {highlight: navMenu.highlight}),
|