apostrophe 4.30.0-alpha.1 → 4.30.0

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 (64) hide show
  1. package/.claude/settings.local.json +15 -0
  2. package/CHANGELOG.md +30 -2
  3. package/eslint.config.js +1 -2
  4. package/lib/mongodb-connect.js +62 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBar.vue +0 -1
  6. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +25 -8
  7. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBreakpointPreviewMode.vue +9 -0
  8. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +20 -2
  9. package/modules/@apostrophecms/area/index.js +10 -5
  10. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +2 -0
  11. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +11 -1
  12. package/modules/@apostrophecms/db/index.js +27 -68
  13. package/modules/@apostrophecms/http/index.js +1 -1
  14. package/modules/@apostrophecms/i18n/i18n/en.json +8 -0
  15. package/modules/@apostrophecms/i18n/index.js +1 -8
  16. package/modules/@apostrophecms/image-widget/index.js +29 -1
  17. package/modules/@apostrophecms/job/index.js +7 -9
  18. package/modules/@apostrophecms/layout-widget/index.js +124 -2
  19. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +89 -6
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +2 -2
  21. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +2 -2
  22. package/modules/@apostrophecms/layout-widget/ui/apos/layout.css +8 -0
  23. package/modules/@apostrophecms/layout-widget/ui/src/layout.css +1 -1
  24. package/modules/@apostrophecms/layout-widget/views/widget.html +3 -3
  25. package/modules/@apostrophecms/login/index.js +13 -15
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +2 -1
  27. package/modules/@apostrophecms/oembed/index.js +18 -13
  28. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposRecentlyEditedIcon.vue +2 -0
  29. package/modules/@apostrophecms/rich-text-widget/index.js +36 -0
  30. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +1 -1
  31. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +1 -1
  32. package/modules/@apostrophecms/styles/index.js +16 -0
  33. package/modules/@apostrophecms/styles/lib/handlers.js +6 -0
  34. package/modules/@apostrophecms/styles/lib/methods.js +93 -0
  35. package/modules/@apostrophecms/styles/lib/presets.js +17 -0
  36. package/modules/@apostrophecms/styles/ui/apos/universal/render.mjs +10 -1
  37. package/modules/@apostrophecms/submitted-draft/ui/apos/components/AposSubmittedDraftIcon.vue +1 -0
  38. package/modules/@apostrophecms/template/views/outerLayoutBase.html +1 -1
  39. package/modules/@apostrophecms/ui/ui/apos/components/AposButton.vue +14 -2
  40. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +29 -5
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +8 -0
  42. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuItem.vue +5 -2
  43. package/modules/@apostrophecms/ui/ui/apos/components/AposLocalePicker.vue +14 -1
  44. package/modules/@apostrophecms/ui/ui/apos/scss/global/_utilities.scss +13 -1
  45. package/modules/@apostrophecms/util/index.js +4 -0
  46. package/modules/@apostrophecms/widget-type/index.js +6 -0
  47. package/modules/@apostrophecms/widget-type/ui/apos/components/AposWidgetEditor.vue +32 -0
  48. package/package.json +13 -13
  49. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  50. package/test/add-missing-schema-fields-project/test.js +3 -11
  51. package/test/assets.js +67 -110
  52. package/test/db.js +15 -24
  53. package/test/job.js +1 -1
  54. package/test/layout-widget-gap.js +530 -0
  55. package/test/login.js +122 -1
  56. package/test/rich-text-widget.js +200 -0
  57. package/test/styles.js +50 -0
  58. package/test-lib/util.js +14 -50
  59. package/claude-tools/detect-handles.js +0 -46
  60. package/claude-tools/minimal-hang-test.js +0 -28
  61. package/claude-tools/mongo-close-test.js +0 -11
  62. package/claude-tools/stdin-ref-test.js +0 -14
  63. package/test/db-tools.js +0 -365
  64. package/test/default-adapter.js +0 -256
@@ -102,7 +102,7 @@ module.exports = {
102
102
  linkHref: {
103
103
  label: 'apostrophe:url',
104
104
  help: 'apostrophe:linkHrefHelp',
105
- type: 'string',
105
+ type: 'url',
106
106
  required: true,
107
107
  if: {
108
108
  linkTo: '_url'
@@ -147,6 +147,7 @@ module.exports = {
147
147
  self.showPlaceholder = self.options.placeholder !== false;
148
148
  self.options.placeholder = true;
149
149
  self.determineBestAssetUrl('placeholder');
150
+ self.addCleanLinkHrefMigration();
150
151
  },
151
152
  handlers(self) {
152
153
  return {
@@ -159,6 +160,33 @@ module.exports = {
159
160
  },
160
161
  methods(self) {
161
162
  return {
163
+ // Clear link hrefs that use dangerous URL schemes (e.g.
164
+ // `javascript:`) and may have been stored before the linkHref
165
+ // schema field was changed to `type: 'url'`. Registered per
166
+ // module instance so subclasses of this widget are migrated
167
+ // under their own widget type and migration name.
168
+ addCleanLinkHrefMigration() {
169
+ self.apos.migration.add(
170
+ `${self.__meta.name}:clean-naughty-link-href`,
171
+ async () => {
172
+ await self.apos.migration.eachWidget({}, async (doc, widget, dotPath) => {
173
+ if (widget.type !== self.name) {
174
+ return;
175
+ }
176
+ if (!widget.linkHref) {
177
+ return;
178
+ }
179
+ if (!self.apos.launder.naughtyHref(widget.linkHref)) {
180
+ return;
181
+ }
182
+ await self.apos.doc.db.updateOne(
183
+ { _id: doc._id },
184
+ { $set: { [`${dotPath}.linkHref`]: '' } }
185
+ );
186
+ });
187
+ }
188
+ );
189
+ },
162
190
  validateAndAddSchemaLabels() {
163
191
  const linkWithType = self.options.linkWithType;
164
192
 
@@ -244,9 +244,7 @@ module.exports = {
244
244
  },
245
245
  setTotal (n) {
246
246
  total = n;
247
- const result = self.setTotal(job, n);
248
- promises.push(result);
249
- return result;
247
+ return self.setTotal(job, n);
250
248
  },
251
249
  setResults (_results) {
252
250
  results = _results;
@@ -414,12 +412,12 @@ module.exports = {
414
412
  //
415
413
  // No promise is returned as this method just updates
416
414
  // the job tracking information in the background.
417
- async setTotal(job, total) {
418
- try {
419
- await self.db.updateOne({ _id: job._id }, { $set: { total } });
420
- } catch (err) {
421
- self.apos.util.error(err);
422
- }
415
+ setTotal(job, total) {
416
+ self.db.updateOne({ _id: job._id }, { $set: { total } }, function (err) {
417
+ if (err) {
418
+ self.apos.util.error(err);
419
+ }
420
+ });
423
421
  },
424
422
  // Mark the given job as ended. If `success`
425
423
  // is true the job is reported as an overall
@@ -19,6 +19,10 @@ module.exports = {
19
19
  gap: '1.5rem',
20
20
  defaultCellHorizontalAlignment: null,
21
21
  defaultCellVerticalAlignment: null,
22
+ // Extra class name(s) to append to the rendered layout-widget area
23
+ // wrapper, in addition to the built-in `layout-widget` class. Accepts
24
+ // a string of space-separated class names.
25
+ className: '',
22
26
  injectStyles: true,
23
27
  minifyStyles: true
24
28
  },
@@ -98,6 +102,25 @@ module.exports = {
98
102
  validateAndIdentifyTypes() {
99
103
  const { column } = self.validateAndIdentifyTypes();
100
104
  self.columnWidgetName = column;
105
+ },
106
+ // Detect the widget-style "gap" field (any styles field whose
107
+ // CSS `property` resolves to `gap`). Only the first match is used.
108
+ // Also detect whether the @apostrophecms/styles module has a
109
+ // site-wide layout gap field configured (via the `layoutGap`
110
+ // preset / `layoutGapDefault: true` marker).
111
+ detectGapFields() {
112
+ const widgetGapFields = self.apos.styles
113
+ .fieldsWithProperty(self.schema, 'gap');
114
+ if (widgetGapFields.length > 1) {
115
+ self.apos.util.warn(
116
+ `[${self.__meta.name}] Multiple style fields produce the ` +
117
+ `CSS \`gap\` property (${widgetGapFields.join(', ')}). ` +
118
+ 'Only the first one will be honoured as the widget-scope gap.'
119
+ );
120
+ }
121
+ self.widgetGapFieldName = widgetGapFields[0] || null;
122
+ self.globalGapEnabled = !!self.apos.modules['@apostrophecms/styles']
123
+ ?.layoutGapFieldName;
101
124
  }
102
125
  }
103
126
  };
@@ -118,6 +141,17 @@ module.exports = {
118
141
  defaultCellHorizontalAlignment: self.options.defaultCellHorizontalAlignment,
119
142
  defaultCellVerticalAlignment: self.options.defaultCellVerticalAlignment
120
143
  },
144
+ widgetGapFieldName: self.widgetGapFieldName || null,
145
+ widgetGapFieldUnit: self.widgetGapFieldName
146
+ ? (self.apos.styles
147
+ .getFieldByPath(self.schema, self.widgetGapFieldName)
148
+ ?.unit || '')
149
+ : '',
150
+ globalGapEnabled: !!self.globalGapEnabled,
151
+ // Opt-in flag read by the generic widget editor: when true,
152
+ // it broadcasts `apos-widget-live-preview` events on the apos bus
153
+ // during the style-only fast path.
154
+ subscribesToLivePreview: !!self.widgetGapFieldName,
121
155
  columnWidgetName: self.columnWidgetName
122
156
  };
123
157
  },
@@ -132,6 +166,7 @@ module.exports = {
132
166
  defaultCellHorizontalAlignment,
133
167
  defaultCellVerticalAlignment
134
168
  } = self.options;
169
+ const widgetGap = self.resolveWidgetGap(widget);
135
170
  return {
136
171
  ..._super(widget, { scene }),
137
172
  columns,
@@ -141,13 +176,53 @@ module.exports = {
141
176
  tablet,
142
177
  gap,
143
178
  defaultCellHorizontalAlignment,
144
- defaultCellVerticalAlignment
179
+ defaultCellVerticalAlignment,
180
+ _gap: widgetGap,
181
+ _gapHasGlobal: !!self.globalGapEnabled
145
182
  };
146
183
  }
147
184
  };
148
185
  },
149
186
  methods(self) {
150
187
  return {
188
+ // Resolve the widget-scope gap for a widget instance, if this
189
+ // module declares a styles field with `property: 'gap'`. Returns
190
+ // the resolved value (with unit, when applicable). When the
191
+ // widget has no explicit value, falls back to the field's `def`.
192
+ // Returns `null` only when no widget gap field is
193
+ // configured, no widget value is set, and no `def` is declared
194
+ // on the field.
195
+ resolveWidgetGap(widget) {
196
+ if (!self.widgetGapFieldName || !widget) {
197
+ return null;
198
+ }
199
+ const field = self.apos.styles.getFieldByPath(
200
+ self.schema, self.widgetGapFieldName
201
+ );
202
+ let value = self.apos.util.get(widget, self.widgetGapFieldName);
203
+ if (value === null || value === undefined || value === '') {
204
+ if (field?.def === null || field?.def === undefined || field?.def === '') {
205
+ return null;
206
+ }
207
+ value = field.def;
208
+ }
209
+ if (field?.unit && typeof value !== 'string') {
210
+ return `${value}${field.unit}`;
211
+ }
212
+ return value;
213
+ },
214
+ // Determine whether the inline `--grid-gap` CSS variable should
215
+ // be omitted on the grid container so the global cascade
216
+ // (`var(--apos-layout-gap, …)`) can take effect. The inline var
217
+ // is omitted whenever:
218
+ // - no widget-scope gap value is set, AND
219
+ // - the global layout-gap field is configured.
220
+ shouldOmitInlineGap(widget, global) {
221
+ if (!self.globalGapEnabled) {
222
+ return false;
223
+ }
224
+ return self.resolveWidgetGap(widget) === null;
225
+ },
151
226
  publicCssNodes(req) {
152
227
  return [
153
228
  {
@@ -277,11 +352,13 @@ module.exports = {
277
352
  const mobileBreakpointPlus = mobileBreakpoint + 1;
278
353
  const tabletBreakpoint = self.options.tablet?.breakpoint || 1024;
279
354
  const tabletBreakpointPlus = tabletBreakpoint + 1;
355
+ const gapDefault = self.options.gap || '0';
280
356
  cssContent = cssContent
281
357
  .replace(/\{\$mobile\}/g, mobileBreakpoint)
282
358
  .replace(/\{\$mobile-plus\}/g, mobileBreakpointPlus)
283
359
  .replace(/\{\$tablet\}/g, tabletBreakpoint)
284
- .replace(/\{\$tablet-plus\}/g, tabletBreakpointPlus);
360
+ .replace(/\{\$tablet-plus\}/g, tabletBreakpointPlus)
361
+ .replace(/\{\$gap-default\}/g, gapDefault);
285
362
 
286
363
  return self.processCss(cssContent, scene);
287
364
  },
@@ -371,6 +448,51 @@ module.exports = {
371
448
  (a.order ?? 0) - (b.order ?? 0)
372
449
  );
373
450
  return items[items.length - 1]._id;
451
+ },
452
+ // Compute the `--grid-gap: <value>;` declaration to inline on the
453
+ // grid container, or an empty string when the cascade should
454
+ // resolve gap via `var(--apos-layout-gap, …)` instead.
455
+ // Honours the priority order:
456
+ // 1. Widget-style gap (when set on this widget instance).
457
+ // 2. Static module option (BC) — when no global gap field is
458
+ // configured, or it has no value.
459
+ // 3. Otherwise, omit the inline var so the global cascade wins.
460
+ // Must be invoked via the widget's own module namespace —
461
+ // `apos.modules[data.manager.__meta.name].gapInlineCss(...)` —
462
+ // so that `self` resolves to the actual subclass and picks up
463
+ // its `widgetGapFieldName` / `globalGapEnabled`.
464
+ gapInlineCss(widget, options, global) {
465
+ const widgetGap = self.resolveWidgetGap(widget);
466
+ if (widgetGap !== null) {
467
+ return ` --grid-gap: ${widgetGap};`;
468
+ }
469
+ if (self.shouldOmitInlineGap(widget, global)) {
470
+ return '';
471
+ }
472
+ const fallback = (options && options.gap) || self.options.gap || '0';
473
+ return ` --grid-gap: ${fallback};`;
474
+ },
475
+ // Build the `aposParentOptions` payload passed by the rendered
476
+ // layout-widget area to the in-place editor (AposAreaLayoutEditor).
477
+ // Includes the widget's resolved gap (from its `gap` styles field,
478
+ // when present) so the live editor's grid container reflects the
479
+ // saved per-widget value rather than only the static module
480
+ // option. Sets `gap: null` to signal the editor to omit
481
+ // `--grid-gap` so the global cascade resolves it through
482
+ // `:root { --apos-layout-gap }`. Must be invoked via the widget's
483
+ // own module namespace, like `gapInlineCss`.
484
+ parentOptionsForArea(widget, options, global) {
485
+ const opts = {
486
+ ...(options || {}),
487
+ widgetId: widget._id
488
+ };
489
+ const widgetGap = self.resolveWidgetGap(widget);
490
+ if (widgetGap !== null) {
491
+ opts.gap = widgetGap;
492
+ } else if (self.shouldOmitInlineGap(widget, global)) {
493
+ opts.gap = null;
494
+ }
495
+ return opts;
374
496
  }
375
497
  };
376
498
  }
@@ -122,6 +122,7 @@
122
122
 
123
123
  <script>
124
124
  import { mapActions } from 'pinia';
125
+ import get from 'lodash/get';
125
126
  import AposAreaEditorLogic from 'Modules/@apostrophecms/area/logic/AposAreaEditor.js';
126
127
  import walkWidgets from 'Modules/@apostrophecms/area/lib/walk-widgets.js';
127
128
  import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget.js';
@@ -144,7 +145,11 @@ export default {
144
145
  return {
145
146
  // 'layout' | 'focus' | 'content'
146
147
  layoutMode: 'content',
147
- layoutDeviceMode: 'desktop'
148
+ layoutDeviceMode: 'desktop',
149
+ // Live snapshot of the parent layout-widget's in-modal style
150
+ // values, fed by `apos-widget-live-preview` bus events from
151
+ // AposWidgetEditor while the user drags style sliders.
152
+ liveWidgetData: null
148
153
  };
149
154
  },
150
155
  computed: {
@@ -152,11 +157,48 @@ export default {
152
157
  return window.apos.modules[this.moduleName] || {};
153
158
  },
154
159
  gridModuleOptions() {
155
- return Object.assign(
156
- {},
157
- this.layoutModuleOptions.grid ?? {},
158
- this.parentOptions
159
- );
160
+ const baseGrid = this.layoutModuleOptions.grid ?? {};
161
+ const parent = this.parentOptions || {};
162
+ const opts = Object.assign({}, baseGrid, parent);
163
+ // Resolve `gap` priority for the live editor:
164
+ const liveGap = this.liveGapValue;
165
+ if (liveGap != null) {
166
+ opts.gap = liveGap;
167
+ } else if (parent.gap != null) {
168
+ opts.gap = parent.gap;
169
+ } else if (
170
+ parent.gap === null ||
171
+ this.layoutModuleOptions.globalGapEnabled
172
+ ) {
173
+ // Parent signalled "use the cascade" (omit `--grid-gap` and let
174
+ // `var(--apos-layout-gap, …)` resolve it on `:root`).
175
+ // The snapshot is recomputed whenever `layoutMode`
176
+ // changes, which is the only cadence the editor needs.
177
+ if (this.layoutMode !== 'content') {
178
+ opts.gap = this.resolvedCascadeGap();
179
+ } else {
180
+ opts.gap = undefined;
181
+ }
182
+ } else {
183
+ opts.gap = baseGrid.gap;
184
+ }
185
+ return opts;
186
+ },
187
+ // Reactive bridge to the parent layout-widget's styles modal.
188
+ liveGapValue() {
189
+ if (this.layoutMode !== 'content') {
190
+ return null;
191
+ }
192
+ const fieldName = this.layoutModuleOptions.widgetGapFieldName;
193
+ if (!fieldName || !this.liveWidgetData) {
194
+ return null;
195
+ }
196
+ const value = get(this.liveWidgetData, fieldName);
197
+ if (value === null || value === undefined || value === '') {
198
+ return null;
199
+ }
200
+ const unit = this.layoutModuleOptions.widgetGapFieldUnit || '';
201
+ return typeof value === 'string' ? value : `${value}${unit}`;
160
202
  },
161
203
  // Meta storage is not yet implemented, return a default meta object
162
204
  layoutMeta() {
@@ -259,6 +301,12 @@ export default {
259
301
  this.emphasizeGrid();
260
302
  } else {
261
303
  this.deEmphasizeGrid();
304
+ // Leaving layout mode: clear the control-suppression flag that was
305
+ // set on the focused widget when we entered layout mode. Without
306
+ // this the layout widget keeps `isSuppressingWidgetControls=true`
307
+ // and its side operation bar stays hidden even though it is the
308
+ // focused widget.
309
+ apos.bus.$emit('clear-focused-widget-control-suppression');
262
310
  }
263
311
  }
264
312
  },
@@ -266,6 +314,8 @@ export default {
266
314
  apos.bus.$on('apos-switch-layout-mode', this.switchLayoutMode);
267
315
  apos.bus.$on('apos-layout-col-delete', this.onRemoveLayoutColumn);
268
316
  apos.bus.$on('apos-edit-styles', this.editStyles);
317
+ apos.bus.$on('apos-widget-live-preview', this.onWidgetLivePreview);
318
+ apos.bus.$on('apos-widget-live-preview-end', this.onWidgetLivePreviewEnd);
269
319
  if (!this.hasLayoutColumnWidgets) {
270
320
  this.onCreateProvision();
271
321
  }
@@ -275,6 +325,8 @@ export default {
275
325
  apos.bus.$off('apos-switch-layout-mode', this.switchLayoutMode);
276
326
  apos.bus.$off('apos-layout-col-delete', this.onRemoveLayoutColumn);
277
327
  apos.bus.$off('apos-edit-styles', this.editStyles);
328
+ apos.bus.$off('apos-widget-live-preview', this.onWidgetLivePreview);
329
+ apos.bus.$off('apos-widget-live-preview-end', this.onWidgetLivePreviewEnd);
278
330
  },
279
331
  methods: {
280
332
  ...mapActions(useWidgetStore, [
@@ -284,6 +336,37 @@ export default {
284
336
  'removeEmphasizedWidget',
285
337
  'setFocusedWidget'
286
338
  ]),
339
+ // Read the current resolved value of the global cascade variable
340
+ // `--apos-layout-gap` from `:root`. Used by `gridModuleOptions` to
341
+ // hand layout/focus modes a real gap value.
342
+ resolvedCascadeGap() {
343
+ if (!document?.documentElement) {
344
+ return undefined;
345
+ }
346
+ const value = window.getComputedStyle(document.documentElement)
347
+ .getPropertyValue('--apos-layout-gap')
348
+ .trim();
349
+ return value || undefined;
350
+ },
351
+ onWidgetLivePreview({ widgetId, data }) {
352
+ if (!widgetId || widgetId !== this.parentOptions?.widgetId) {
353
+ return;
354
+ }
355
+ this.liveWidgetData = data;
356
+ },
357
+ onWidgetLivePreviewEnd({ widgetId, reason }) {
358
+ if (!widgetId || widgetId !== this.parentOptions?.widgetId) {
359
+ return;
360
+ }
361
+ // On `save` keep the live snapshot in place: clearing
362
+ // here would expose the old `parent.gap` value for one frame.
363
+ // On `cancel` / `unmount` drop the live value immediately so the
364
+ // editor falls back to the SSR-rendered parent options.
365
+ if (reason === 'save') {
366
+ return;
367
+ }
368
+ this.liveWidgetData = null;
369
+ },
287
370
  clickOnGrid() {
288
371
  if (this.parentOptions.widgetId) {
289
372
  this.setFocusedWidget(this.parentOptions.widgetId, this.areaId);
@@ -16,7 +16,7 @@
16
16
  data-mobile-auto="true"
17
17
  :style="{
18
18
  '--grid-columns': gridState.columns,
19
- '--grid-gap': gridState.options.gap || '0',
19
+ '--grid-gap': gridState.options.gap ?? null,
20
20
  '--grid-rows': 'auto',
21
21
  '--mobile-grid-rows': 'auto',
22
22
  '--tablet-grid-rows': 'auto',
@@ -310,7 +310,7 @@ export default {
310
310
  display: grid;
311
311
  grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
312
312
  grid-template-rows: repeat(var(--grid-rows), auto);
313
- grid-gap: var(--grid-gap, 0);
313
+ gap: var(--grid-gap, var(--apos-layout-gap, 0));
314
314
  justify-items: var(--justify-items);
315
315
  /* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */
316
316
  align-items: var(--align-items);
@@ -5,7 +5,7 @@
5
5
  data-apos-test="aposLayoutContainerClone"
6
6
  :style="{
7
7
  '--grid-columns': gridState.columns,
8
- '--grid-gap': gridState.options.gap,
8
+ '--grid-gap': gridState.options.gap ?? null,
9
9
  }"
10
10
  @mousemove="onMouseMove($event)"
11
11
  >
@@ -938,7 +938,7 @@ $resize-ui-z-index: 2;
938
938
  inset: 0;
939
939
  grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
940
940
  grid-template-rows: repeat(var(--grid-rows), auto);
941
- grid-gap: var(--grid-gap, 0);
941
+ gap: var(--grid-gap, var(--apos-layout-gap, 0));
942
942
 
943
943
  &.is-moving,
944
944
  &.is-resizing {
@@ -1,3 +1,11 @@
1
+ /*
2
+ Reserve the same vertical footprint for the SSR markup.
3
+ This stylesheet is only injected on the apos scene in edit mode.
4
+ */
5
+ .layout-widget > div {
6
+ min-height: 150px;
7
+ }
8
+
1
9
  /* Tablet rules for admin interface */
2
10
  /* stylelint-disable-next-line media-feature-name-allowed-list */
3
11
  @media screen and (min-width: {$mobile-plus}px) and (max-width: {$tablet}px) {
@@ -2,7 +2,7 @@
2
2
  display: grid;
3
3
  grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
4
4
  grid-template-rows: repeat(var(--grid-rows), auto);
5
- grid-gap: var(--grid-gap, 0);
5
+ gap: var(--grid-gap, var(--apos-layout-gap, {$gap-default}));
6
6
  justify-items: var(--justify-items);
7
7
  /* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */
8
8
  align-items: var(--align-items);
@@ -1,13 +1,13 @@
1
1
  {% area data.widget, 'columns' with {
2
2
  aposStyle: '--grid-columns: ' + (data.options.columns or data.manager.options.columns) + ';' +
3
- ' --grid-gap: ' + (data.options.gap or data.manager.options.gap or '0') + ';' +
3
+ apos.modules[data.manager.__meta.name].gapInlineCss(data.widget, data.options, data.global) +
4
4
  ' --grid-rows: auto;' +
5
5
  ' --mobile-grid-rows: auto;' +
6
6
  ' --tablet-grid-rows: auto;' +
7
7
  ' --justify-items: ' + (data.options.defaultCellHorizontalAlignment or data.manager.options.defaultCellHorizontalAlignment or 'stretch') + ';' +
8
8
  ' --align-items: ' + (data.options.defaultCellVerticalAlignment or data.manager.options.defaultCellVerticalAlignment or 'stretch') + ';',
9
- aposClassName: 'layout-widget',
10
- aposParentOptions: data.options | merge({ widgetId: data.widget._id }),
9
+ aposClassName: ('layout-widget' + ((' ' + (data.options.className or data.manager.options.className)) if (data.options.className or data.manager.options.className))),
10
+ aposParentOptions: apos.modules[data.manager.__meta.name].parentOptionsForArea(data.widget, data.options, data.global),
11
11
  aposAttrs: {
12
12
  'tablet-auto': true,
13
13
  'mobile-auto': true
@@ -295,7 +295,17 @@ module.exports = {
295
295
  async resetRequest(req) {
296
296
  const MIN_RESPONSE_TIME = 2000;
297
297
  const startTime = Date.now();
298
- const site = (req.headers.host || '').replace(/:\d+$/, '');
298
+ // Refuse to construct reset URLs from the request's Host header,
299
+ // which is attacker-controlled. Operators must configure the
300
+ // baseUrl option (or the APOS_BASE_URL environment variable) so
301
+ // the link in the reset email points to the real site rather than
302
+ // wherever the attacker aimed their crafted request.
303
+ if (!self.apos.baseUrl) {
304
+ throw self.apos.error(
305
+ 'invalid',
306
+ 'The baseUrl option (or APOS_BASE_URL environment variable) must be configured to enable password reset'
307
+ );
308
+ }
299
309
  const email = self.apos.launder.string(req.body.email);
300
310
  if (!email.length) {
301
311
  throw self.apos.error('invalid', req.t('apostrophe:loginResetEmailRequired'));
@@ -319,22 +329,10 @@ module.exports = {
319
329
  user.passwordReset = reset;
320
330
  user.passwordResetAt = new Date();
321
331
  await self.apos.user.update(req, user, { permissions: false });
322
- let port = (req.headers.host || '').split(':')[1];
323
- if (!port || [ '80', '443' ].includes(port)) {
324
- port = '';
325
- } else {
326
- port = `:${port}`;
327
- }
328
- const parsed = new URL(
329
- req.absoluteUrl,
330
- self.apos.baseUrl
331
- ? undefined
332
- : `${req.protocol}://${req.hostname}${port}`
333
- );
334
- parsed.pathname = self.login();
335
- parsed.search = '?';
332
+ const parsed = new URL(self.login(), self.apos.baseUrl);
336
333
  parsed.searchParams.append('reset', reset);
337
334
  parsed.searchParams.append('email', user.email);
335
+ const site = parsed.hostname;
338
336
  try {
339
337
  await self.email(req, 'passwordResetEmail', {
340
338
  user,
@@ -212,7 +212,8 @@ const nonDraggableElements = [
212
212
  '.apos-field--inline-array-field',
213
213
  '.apos-field--inline-array-table-with-remove-button-field',
214
214
  '.apos-field--inline-array-table-field',
215
- '.apos-input-color__sample-picker'
215
+ '.apos-input-color__sample-picker',
216
+ '.apos-input-array-inline-table'
216
217
  ];
217
218
 
218
219
  const resizeSides = [
@@ -16,10 +16,13 @@ const cheerio = require('cheerio');
16
16
  // widely trusted sites are already allowlisted.
17
17
  //
18
18
  // Your `allowlist` option is concatenated with `oembetter`'s standard
19
- // allowlist, plus wufoo.com, infogr.am, and slideshare.net.
19
+ // allowlist, plus wufoo.com, infogr.am and slideshare.net.
20
20
  //
21
21
  // Your `endpoints` option is concatenated with `oembetter`'s standard
22
22
  // endpoints list.
23
+ //
24
+ // If you wish to completely override the behavior, set
25
+ // `minimumAllowlist` and `minimumEndpoints` instead.
23
26
 
24
27
  module.exports = {
25
28
  options: {
@@ -42,28 +45,30 @@ module.exports = {
42
45
  // Don't permit oembed of untrusted sites, which could
43
46
  // lead to XSS attacks
44
47
 
45
- self.oembetter.allowlist(self.oembetter.suggestedAllowlist.concat(
46
- self.options.allowlist || [],
47
- [
48
- 'wufoo.com',
49
- 'infogr.am',
50
- 'slideshare.net'
51
- ])
52
- );
48
+ const minimumAllowlist = self.options.minimumAllowlist || [
49
+ ...self.oembetter.suggestedAllowlist,
50
+ 'wufoo.com',
51
+ 'infogr.am',
52
+ 'slideshare.net'
53
+ ];
54
+
55
+ self.oembetter.allowlist(minimumAllowlist.concat(self.options.allowlist || []));
56
+
57
+ const minimumEndpoints = self.options.minimumEndpoints || self.oembetter.suggestedEndpoints;
53
58
  self.oembetter.endpoints(
54
- self.oembetter.suggestedEndpoints.concat(self.options.endpoints || [])
59
+ minimumEndpoints.concat(self.options.endpoints || [])
55
60
  );
56
61
  },
57
62
 
58
63
  // Enhances oembetter to support services better or to support services
59
- // that have no oembed support by default. Called by `afterConstruct`.
60
- // Extend this method to add additional `oembetter` filters.
64
+ // that have no oembed support by default.
65
+ //
66
+ // Extend or override this method to change or add oembetter filters.
61
67
 
62
68
  enhanceOembetter() {
63
69
  require('./lib/youtube.js')(self, self.oembetter);
64
70
  require('./lib/vimeo.js')(self, self.oembetter);
65
71
  require('./lib/wufoo.js')(self, self.oembetter);
66
- require('./lib/infogram.js')(self, self.oembetter);
67
72
  },
68
73
 
69
74
  // This method fetches the specified URL, determines its best embedded
@@ -6,6 +6,8 @@
6
6
  :modifiers="['small', 'no-motion']"
7
7
  :tooltip="$t('apostrophe:recentlyEditedDocuments')"
8
8
  :icon-only="true"
9
+ :label="'apostrophe:recentlyEditedManagerOpen'"
10
+ :attrs="{ 'aria-label': $t('apostrophe:recentlyEditedManagerOpen') }"
9
11
  @click="open"
10
12
  />
11
13
  </template>