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.
@@ -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
- if (raw !== undefined) return String(e.data?.[raw] ?? '');
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 || '&nbsp;'}</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 || '&nbsp;'}</div>${footer}</div>`;
646
679
  }).join('\n');
647
- return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`;
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
- return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`;
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
- return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
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
- return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
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)}"` : '',
@@ -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 expiresAt = new Date(Date.now() + resetLifetime().ms).toISOString();
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}),