apostrophe 3.43.0 → 3.44.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 (27) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/force-deploy +1 -0
  3. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +3 -2
  4. package/modules/@apostrophecms/doc-type/index.js +10 -0
  5. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +17 -3
  6. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -5
  7. package/modules/@apostrophecms/i18n/i18n/de.json +1 -0
  8. package/modules/@apostrophecms/i18n/i18n/en.json +1 -0
  9. package/modules/@apostrophecms/i18n/i18n/es.json +1 -0
  10. package/modules/@apostrophecms/i18n/i18n/fr.json +1 -0
  11. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +1 -0
  12. package/modules/@apostrophecms/i18n/i18n/sk.json +1 -0
  13. package/modules/@apostrophecms/i18n/index.js +3 -0
  14. package/modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue +25 -13
  15. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +1 -1
  16. package/modules/@apostrophecms/modal/ui/apos/mixins/AposEditorMixin.js +2 -1
  17. package/modules/@apostrophecms/modal/ui/apos/mixins/AposModalTabsMixin.js +3 -2
  18. package/modules/@apostrophecms/module/index.js +11 -0
  19. package/modules/@apostrophecms/page/index.js +13 -5
  20. package/modules/@apostrophecms/piece-type/index.js +3 -3
  21. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +3 -3
  22. package/modules/@apostrophecms/schema/ui/apos/components/AposInputCheckboxes.vue +27 -3
  23. package/modules/@apostrophecms/ui/ui/apos/components/AposCombo.vue +381 -0
  24. package/package.json +1 -1
  25. package/test/pieces-public-api.js +32 -0
  26. package/test/pieces.js +16 -48
  27. package/test-lib/util.js +52 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.44.0 (2023-04-13)
4
+
5
+ ### Adds
6
+
7
+ * `checkboxes` fields now support a new `style: 'combobox'` option for a better multiple-select experience when there
8
+ are many choices.
9
+ * If the new `guestApiAccess` option is set to `true` for a piece type or for `@apostrophecms/page`,
10
+ Apostrophe will allow all logged-in users to access the GET-method REST APIs of that
11
+ module, not just users with editing privileges, even if `publicApiProjection` is not set.
12
+ This is useful when the goal is to allow REST API access to "guest" users who have
13
+ project-specific reasons to fetch access content via REST APIs.
14
+ * `test-lib/utils.js` has new `createUser` and `loginAs` methods for the convenience of
15
+ those writing mocha tests of Apostrophe modules.
16
+
3
17
  ## 3.43.0 (2023-03-29)
4
18
 
5
19
  ### Adds
package/force-deploy ADDED
@@ -0,0 +1 @@
1
+ 4
@@ -79,10 +79,11 @@ export default {
79
79
  },
80
80
  computed: {
81
81
  contextBarActive() {
82
- return window.apos.adminBar.contextBar && this.canEdit;
82
+ return window.apos.adminBar.contextBar && (this.canEdit || this.moduleOptions.canLocalize);
83
83
  },
84
84
  canEdit() {
85
- return this.context._edit || ((this.context.aposLocale && this.context.aposLocale.endsWith(':published')) && this.draftIsEditable);
85
+ return this.context._edit || ((this.context.aposLocale && this.context.aposLocale.endsWith(':published')) &&
86
+ this.draftIsEditable);
86
87
  },
87
88
  classes() {
88
89
  if (!this.contextBarActive) {
@@ -763,6 +763,11 @@ module.exports = {
763
763
  async findOneForCopying(req, criteria) {
764
764
  return self.findOneForEditing(req, criteria);
765
765
  },
766
+ // Identical to findOneForEditing by default, but could be
767
+ // overridden usefully in subclasses.
768
+ async findOneForLocalizing(req, criteria) {
769
+ return self.findOneForEditing(req, criteria);
770
+ },
766
771
  // Submit the current draft for review. The identity
767
772
  // of `req.user` is associated with the submission.
768
773
  // Returns the `submitted` object, with `by`, `byId`,
@@ -1424,8 +1429,13 @@ module.exports = {
1424
1429
  label,
1425
1430
  pluralLabel,
1426
1431
  relatedDocument: self.options.relatedDocument,
1432
+ canEdit: self.apos.permission.can(req, 'edit', self.name, 'draft'),
1427
1433
  canPublish: self.apos.permission.can(req, 'publish', self.name)
1428
1434
  };
1435
+ browserOptions.canLocalize = browserOptions.canEdit &&
1436
+ self.options.localized &&
1437
+ Object.keys(self.apos.i18n.locales).length > 1 &&
1438
+ Object.values(self.apos.i18n.locales).some(locale => locale._edit);
1429
1439
  browserOptions.action = self.action;
1430
1440
  browserOptions.schema = self.allowedSchema(req);
1431
1441
  browserOptions.localized = self.isLocalized();
@@ -181,6 +181,10 @@ export default {
181
181
  return menu;
182
182
  },
183
183
  customMenusByContext() {
184
+ if (!this.canEdit) {
185
+ return [];
186
+ }
187
+
184
188
  const menus = this.customOperationsByContext
185
189
  .map(op => ({
186
190
  label: op.label,
@@ -233,7 +237,7 @@ export default {
233
237
  }
234
238
  },
235
239
  canDismissSubmission() {
236
- return this.context.submitted && (this.canPublish || (this.context.submitted.byId === apos.login.user._id));
240
+ return this.canEdit && this.context.submitted && (this.canPublish || (this.context.submitted.byId === apos.login.user._id));
237
241
  },
238
242
  canDiscardDraft() {
239
243
  if (!this.manuallyPublished) {
@@ -242,6 +246,9 @@ export default {
242
246
  if (!this.context._id) {
243
247
  return false;
244
248
  }
249
+ if (!this.canEdit) {
250
+ return false;
251
+ }
245
252
  return (
246
253
  (!this.context.lastPublishedAt) &&
247
254
  !this.moduleOptions.singleton
@@ -251,10 +258,12 @@ export default {
251
258
  );
252
259
  },
253
260
  canLocalize() {
254
- return (Object.keys(apos.i18n.locales).length > 1) && this.moduleOptions.localized && this.context._id;
261
+ return this.moduleOptions.canLocalize &&
262
+ this.context._id;
255
263
  },
256
264
  canArchive() {
257
265
  return (
266
+ this.canEdit &&
258
267
  this.context._id &&
259
268
  !this.moduleOptions.singleton &&
260
269
  !this.context.archived &&
@@ -264,6 +273,7 @@ export default {
264
273
  },
265
274
  canUnpublish() {
266
275
  return (
276
+ this.canEdit &&
267
277
  !this.context.parked &&
268
278
  this.moduleOptions.canPublish &&
269
279
  this.context.lastPublishedAt &&
@@ -271,10 +281,14 @@ export default {
271
281
  );
272
282
  },
273
283
  canCopy() {
274
- return this.canEdit && !this.moduleOptions.singleton && this.context._id;
284
+ return this.canEdit &&
285
+ this.moduleOptions.canEdit &&
286
+ !this.moduleOptions.singleton &&
287
+ this.context._id;
275
288
  },
276
289
  canRestore() {
277
290
  return (
291
+ this.canEdit &&
278
292
  this.context._id &&
279
293
  this.context.archived &&
280
294
  ((this.moduleOptions.canPublish && this.context.lastPublishedAt) || !this.manuallyPublished)
@@ -162,6 +162,7 @@ export default {
162
162
  ref: null
163
163
  },
164
164
  published: null,
165
+ readOnly: false,
165
166
  restoreOnly: false,
166
167
  saveMenu: null,
167
168
  generation: 0
@@ -174,14 +175,24 @@ export default {
174
175
  followingUtils() {
175
176
  return this.followingValues('utility');
176
177
  },
178
+ canEdit() {
179
+ if (this.original && this.original._id) {
180
+ return this.original._edit || this.moduleOptions.canEdit;
181
+ }
182
+
183
+ return this.moduleOptions.canEdit;
184
+ },
177
185
  canPublish() {
178
186
  if (this.original && this.original._id) {
179
- return this.original._publish;
187
+ return this.original._publish || this.moduleOptions.canPublish;
180
188
  }
181
189
 
182
190
  return this.moduleOptions.canPublish;
183
191
  },
184
192
  saveDisabled() {
193
+ if (!this.canEdit) {
194
+ return true;
195
+ }
185
196
  if (this.restoreOnly) {
186
197
  // Can always restore if it's a read-only view of the archive
187
198
  return false;
@@ -421,10 +432,6 @@ export default {
421
432
  async loadDoc() {
422
433
  let docData;
423
434
  try {
424
- if (!await this.lock(this.getOnePath, this.docId)) {
425
- await this.lockNotAvailable();
426
- return;
427
- }
428
435
  docData = await apos.http.get(this.getOnePath, {
429
436
  busy: true,
430
437
  qs: {
@@ -438,6 +445,12 @@ export default {
438
445
  } else {
439
446
  this.restoreOnly = false;
440
447
  }
448
+ const canEdit = docData._edit || this.moduleOptions.canEdit;
449
+ this.readOnly = canEdit === false;
450
+ if (canEdit && !await this.lock(this.getOnePath, this.docId)) {
451
+ await this.lockNotAvailable();
452
+ return;
453
+ }
441
454
  } catch {
442
455
  await apos.notify('apostrophe:loadDocFailed', {
443
456
  type: 'warning',
@@ -4,6 +4,7 @@
4
4
  "addWidgetType": "{{ label }} hinzufügen",
5
5
  "admin": "Administrator",
6
6
  "affirmativeLabel": "Ja, fortsetzen.",
7
+ "allSelected": "Alle ausgewählt",
7
8
  "altText": "Alternativtext",
8
9
  "altTextHelp": "Bildbeschreibung oder alternativer Text, der für Bildschirmleser verwendet wird.",
9
10
  "any": "Irgendwas",
@@ -9,6 +9,7 @@
9
9
  "addWidgetType": "Add {{ label }}",
10
10
  "admin": "Admin",
11
11
  "affirmativeLabel": "Yes, continue.",
12
+ "allSelected": "All Selected",
12
13
  "altText": "Alt Text",
13
14
  "altTextHelp": "Image description used for accessibility",
14
15
  "anchorId": "Anchor ID",
@@ -3,6 +3,7 @@
3
3
  "addItem": "Añadir Elemento",
4
4
  "addWidgetType": "Añadir {{ label }}",
5
5
  "admin": "Administrador",
6
+ "allSelected": "Todo seleccionado",
6
7
  "altText": "Texto Alternativo",
7
8
  "altTextHelp": "Descripción de imagen utilizada para accesibilidad",
8
9
  "any": "Cualquiera",
@@ -3,6 +3,7 @@
3
3
  "addItem": "Ajouter un élément",
4
4
  "addWidgetType": "Ajouter {{ label }}",
5
5
  "affirmativeLabel": "Oui, continuer.",
6
+ "allSelected": "Tout Selectionné",
6
7
  "altText": "Texte alternatif",
7
8
  "altTextHelp": "Description d'image utilisée pour l'accessibilité",
8
9
  "any": "Quelconque",
@@ -3,6 +3,7 @@
3
3
  "addItem": "Adicionar Item",
4
4
  "addWidgetType": "Adicionar {{ label }}",
5
5
  "admin": "Admin",
6
+ "allSelected": "Tudo Selecionado",
6
7
  "altText": "Texto Alternativo",
7
8
  "altTextHelp": "Descrição da imagem usada para acessibilidade",
8
9
  "any": "Qualquer",
@@ -4,6 +4,7 @@
4
4
  "addWidgetType": "Pridať {{ label }}",
5
5
  "admin": "Admin",
6
6
  "affirmativeLabel": "Áno, pokračovať.",
7
+ "allSelected": "Všetky Vybrané",
7
8
  "altText": "Alternatívny text",
8
9
  "altTextHelp": "Alternatívny popis obrázkov pre zlepšenú prístupnosť",
9
10
  "any": "ľubovoľný",
@@ -584,6 +584,9 @@ module.exports = {
584
584
  label: 'English'
585
585
  }
586
586
  };
587
+ for (const locale in locales) {
588
+ locales[locale]._edit = true;
589
+ }
587
590
  verifyLocales(locales, self.apos.options.baseUrl);
588
591
  return locales;
589
592
  },
@@ -278,7 +278,8 @@ export default {
278
278
  ([ locale, options ]) => {
279
279
  return {
280
280
  name: locale,
281
- label: options.label || locale
281
+ label: options.label || locale,
282
+ _edit: options._edit
282
283
  };
283
284
  }
284
285
  ),
@@ -382,12 +383,12 @@ export default {
382
383
  : apos.modules[this.doc.type].action;
383
384
  },
384
385
  filteredLocales() {
385
- return this.locales.filter(({ name, label }) => {
386
- const matches = term =>
387
- term
388
- .toLowerCase()
389
- .includes(this.wizard.sections.selectLocales.filter.toLowerCase());
386
+ const matches = term =>
387
+ term
388
+ .toLowerCase()
389
+ .includes(this.wizard.sections.selectLocales.filter.toLowerCase());
390
390
 
391
+ return this.locales.filter(({ name, label }) => {
391
392
  return matches(name) || matches(label);
392
393
  });
393
394
  },
@@ -395,7 +396,7 @@ export default {
395
396
  return this.wizard.values.toLocales.data;
396
397
  },
397
398
  allSelected() {
398
- return this.selectedLocales.length === this.locales.filter(locale => !this.isCurrentLocale(locale)).length;
399
+ return this.selectedLocales.length === this.locales.filter(locale => !this.isCurrentLocale(locale) && this.canEditLocale(locale)).length;
399
400
  },
400
401
  relatedDocTypes() {
401
402
  const types = {};
@@ -532,6 +533,9 @@ export default {
532
533
  isCurrentLocale(locale) {
533
534
  return window.apos.i18n.locale === locale.name;
534
535
  },
536
+ canEditLocale(locale) {
537
+ return !!locale._edit;
538
+ },
535
539
  isSelected(locale) {
536
540
  return this.wizard.values.toLocales.data.some(
537
541
  ({ name }) => name === locale.name
@@ -541,13 +545,13 @@ export default {
541
545
  return !!this.localized[locale.name];
542
546
  },
543
547
  selectAll() {
544
- this.wizard.values.toLocales.data = this.locales.filter(locale => !this.isCurrentLocale(locale));
548
+ this.wizard.values.toLocales.data = this.locales.filter(locale => !this.isCurrentLocale(locale) && this.canEditLocale(locale));
545
549
  },
546
550
  deselectAll() {
547
551
  this.wizard.values.toLocales.data = [];
548
552
  },
549
553
  toggleLocale(locale) {
550
- if (!this.isSelected(locale) && !this.isCurrentLocale(locale)) {
554
+ if (!this.isSelected(locale) && !this.isCurrentLocale(locale) && this.canEditLocale(locale)) {
551
555
  this.wizard.values.toLocales.data.push(locale);
552
556
  } else if (this.isSelected(locale)) {
553
557
  this.wizard.values.toLocales.data = this.wizard.values.toLocales.data.filter(l => l !== locale);
@@ -590,6 +594,9 @@ export default {
590
594
  if (this.isCurrentLocale(locale)) {
591
595
  classes['apos-current-locale'] = true;
592
596
  }
597
+ if (!this.canEditLocale(locale)) {
598
+ classes['apos-disabled-locale'] = true;
599
+ }
593
600
  return classes;
594
601
  },
595
602
  // Singular type name for label (returns an i18next key)
@@ -948,15 +955,18 @@ export default {
948
955
  line-height: 1;
949
956
  border-radius: var(--a-border-radius);
950
957
 
951
- &:not(.apos-current-locale) {
958
+ &:not(.apos-current-locale),
959
+ &:not(.apos-disabled-locale) {
952
960
  cursor: pointer;
953
961
  }
954
962
 
955
- &:not(.apos-current-locale):hover {
963
+ &:not(.apos-current-locale):hover,
964
+ &:not(.apos-disabled-locale):hover {
956
965
  background-color: var(--a-base-10);
957
966
  }
958
967
 
959
- &:not(.apos-current-locale):active {
968
+ &:not(.apos-current-locale):active,
969
+ &:not(.apos-disabled-locale):active {
960
970
  background-color: var(--a-base-9);
961
971
  }
962
972
 
@@ -974,11 +984,13 @@ export default {
974
984
  }
975
985
 
976
986
  &.apos-current-locale,
987
+ &.apos-disabled-locale,
977
988
  .apos-current-locale-icon {
978
989
  color: var(--a-base-5);
979
990
  }
980
991
 
981
- &.apos-current-locale {
992
+ &.apos-current-locale,
993
+ &.apos-disabled-locale {
982
994
  font-style: italic;
983
995
  }
984
996
 
@@ -158,7 +158,7 @@ export default {
158
158
  return window.apos.modules[this.activeMedia.type] || {};
159
159
  },
160
160
  canLocalize() {
161
- return (Object.keys(apos.i18n.locales).length > 1) && this.moduleOptions.localized && this.activeMedia._id;
161
+ return this.moduleOptions.canLocalize && this.activeMedia._id;
162
162
  },
163
163
  moreMenu() {
164
164
  const menu = [ {
@@ -24,6 +24,7 @@ export default {
24
24
  },
25
25
  serverErrors: null,
26
26
  restoreOnly: false,
27
+ readOnly: false,
27
28
  changed: [],
28
29
  externalConditionsResults: {}
29
30
  };
@@ -32,7 +33,7 @@ export default {
32
33
  computed: {
33
34
  schema() {
34
35
  let schema = (this.moduleOptions.schema || []).filter(field => apos.schema.components.fields[field.type]);
35
- if (this.restoreOnly) {
36
+ if (this.restoreOnly || this.readOnly) {
36
37
  schema = klona(schema);
37
38
  for (const field of schema) {
38
39
  field.readOnly = true;
@@ -48,10 +48,11 @@ export default {
48
48
  if (key !== 'utility') {
49
49
  tabs.push({
50
50
  name: key,
51
- label: this.groups[key].label
51
+ label: this.groups[key].label,
52
+ fields: this.groups[key].fields
52
53
  });
53
54
  }
54
- };
55
+ }
55
56
 
56
57
  return tabs;
57
58
  }
@@ -802,6 +802,17 @@ module.exports = {
802
802
  }
803
803
  },
804
804
 
805
+ // Modules that have REST APIs use this method
806
+ // to determine if a request is qualified to access
807
+ // it without restriction to the `publicApiProjection`
808
+ canAccessApi(req) {
809
+ if (self.options.guestApiAccess) {
810
+ return !!req.user;
811
+ } else {
812
+ return self.apos.permission.can(req, 'view-draft');
813
+ }
814
+ },
815
+
805
816
  // Merge in the event emitter / responder capabilities
806
817
  ...require('./lib/events.js')(self)
807
818
  };
@@ -133,7 +133,7 @@ module.exports = {
133
133
  const autocomplete = self.apos.launder.string(req.query.autocomplete);
134
134
 
135
135
  if (autocomplete.length) {
136
- if (!self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type')) {
136
+ if (!self.apos.permission.can(req, 'view', '@apostrophecms/any-page-type')) {
137
137
  throw self.apos.error('forbidden');
138
138
  }
139
139
  return {
@@ -145,7 +145,7 @@ module.exports = {
145
145
  }
146
146
 
147
147
  if (all) {
148
- if (!self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type')) {
148
+ if (!self.apos.permission.can(req, 'view', '@apostrophecms/any-page-type')) {
149
149
  throw self.apos.error('forbidden');
150
150
  }
151
151
  const page = await self.getRestQuery(req).permission(false).and({ level: 0 }).children({
@@ -418,7 +418,7 @@ module.exports = {
418
418
  },
419
419
  ':_id/localize': async (req) => {
420
420
  const _id = self.inferIdLocaleAndMode(req, req.params._id);
421
- const draft = await self.findOneForEditing(req.clone({
421
+ const draft = await self.findOneForLocalizing(req.clone({
422
422
  mode: 'draft'
423
423
  }), {
424
424
  aposDocId: _id.split(':')[0]
@@ -819,6 +819,11 @@ database.`);
819
819
  // A list of all valid page types, including parked pages etc. This is
820
820
  // not a menu of choices for creating a page manually
821
821
  browserOptions.validPageTypes = self.apos.instancesOf('@apostrophecms/page-type').map(module => module.__meta.name);
822
+ browserOptions.canEdit = self.apos.permission.can(req, 'edit', '@apostrophecms/any-page-type', 'draft');
823
+ browserOptions.canLocalize = browserOptions.canEdit &&
824
+ browserOptions.localized &&
825
+ Object.keys(self.apos.i18n.locales).length > 1 &&
826
+ Object.values(self.apos.i18n.locales).some(locale => locale._edit);
822
827
  return browserOptions;
823
828
  },
824
829
  // Returns a query that finds pages the current user can edit
@@ -2212,7 +2217,7 @@ database.`);
2212
2217
  .applyBuildersSafely(req.query);
2213
2218
  // Minimum standard for a REST query without a public projection
2214
2219
  // is being allowed to view drafts on the site
2215
- if (!self.apos.permission.can(req, 'view-draft')) {
2220
+ if (!self.canAccessApi(req)) {
2216
2221
  if (!self.options.publicApiProjection) {
2217
2222
  // Shouldn't be needed thanks to publicApiCheck, but be sure
2218
2223
  query.and({
@@ -2243,6 +2248,9 @@ database.`);
2243
2248
  async findOneForEditing(req, criteria, builders) {
2244
2249
  return self.findForEditing(req, criteria, builders).toObject();
2245
2250
  },
2251
+ async findOneForLocalizing(req, criteria, builders) {
2252
+ return self.findForEditing(req, criteria, builders).toObject();
2253
+ },
2246
2254
  // Throws a `notfound` exception if a public API projection is
2247
2255
  // not specified and the user does not have the `view-draft` permission,
2248
2256
  // which all roles capable of editing the site at all will have. This is needed because
@@ -2250,7 +2258,7 @@ database.`);
2250
2258
  // we also want to flunk all public access to REST APIs if not specifically configured.
2251
2259
  publicApiCheck(req) {
2252
2260
  if (!self.options.publicApiProjection) {
2253
- if (!self.apos.permission.can(req, 'view-draft')) {
2261
+ if (!self.canAccessApi(req)) {
2254
2262
  throw self.apos.error('notfound');
2255
2263
  }
2256
2264
  }
@@ -408,7 +408,7 @@ module.exports = {
408
408
  },
409
409
  ':_id/localize': async (req) => {
410
410
  const _id = self.inferIdLocaleAndMode(req, req.params._id);
411
- const draft = await self.findOneForEditing(req.clone({
411
+ const draft = await self.findOneForLocalizing(req.clone({
412
412
  mode: 'draft'
413
413
  }), {
414
414
  aposDocId: _id.split(':')[0]
@@ -1002,7 +1002,7 @@ module.exports = {
1002
1002
  getRestQuery(req) {
1003
1003
  const query = self.find(req).attachments(true);
1004
1004
  query.applyBuildersSafely(req.query);
1005
- if (!self.apos.permission.can(req, 'view-draft')) {
1005
+ if (!self.canAccessApi(req)) {
1006
1006
  if (!self.options.publicApiProjection) {
1007
1007
  // Shouldn't be needed thanks to publicApiCheck, but be sure
1008
1008
  query.and({
@@ -1024,7 +1024,7 @@ module.exports = {
1024
1024
  // we also want to flunk all public access to REST APIs if not specifically configured.
1025
1025
  publicApiCheck(req) {
1026
1026
  if (!self.options.publicApiProjection) {
1027
- if (!self.apos.permission.can(req, 'view-draft')) {
1027
+ if (!self.canAccessApi(req)) {
1028
1028
  throw self.apos.error('notfound');
1029
1029
  }
1030
1030
  }
@@ -78,7 +78,7 @@
78
78
  />
79
79
  </td>
80
80
  <!-- append the context menu -->
81
- <td v-if="canEdit(item)" class="apos-table__cell apos-table__cell--context-menu">
81
+ <td class="apos-table__cell apos-table__cell--context-menu">
82
82
  <AposCellContextMenu
83
83
  :state="state[item._id]" :item="item"
84
84
  :draft="item"
@@ -164,10 +164,10 @@ export default {
164
164
  methods: {
165
165
  canEdit(item) {
166
166
  if (item._id) {
167
- return item._edit;
167
+ return item._edit || this.options.canLocalize;
168
168
  }
169
169
 
170
- return this.options.canEdit;
170
+ return this.options.canEdit || this.options.canLocalize;
171
171
  },
172
172
  over(id) {
173
173
  this.state[id].hover = true;
@@ -6,7 +6,16 @@
6
6
  :display-options="displayOptions"
7
7
  >
8
8
  <template #body>
9
+ <AposCombo
10
+ v-if="field.style === 'combo' && choices.length"
11
+ :choices="choices"
12
+ :field="field"
13
+ :value="value"
14
+ @select-items="selectItems"
15
+ />
16
+
9
17
  <AposCheckbox
18
+ v-else
10
19
  :for="getChoiceId(uid, choice.value)"
11
20
  v-for="choice in choices"
12
21
  :key="choice.value"
@@ -26,7 +35,7 @@ import AposInputChoicesMixin from 'Modules/@apostrophecms/schema/mixins/AposInpu
26
35
  export default {
27
36
  name: 'AposInputCheckboxes',
28
37
  mixins: [ AposInputMixin, AposInputChoicesMixin ],
29
- beforeMount: function () {
38
+ beforeMount () {
30
39
  this.value.data = Array.isArray(this.value.data) ? this.value.data : [];
31
40
  },
32
41
  methods: {
@@ -49,12 +58,12 @@ export default {
49
58
 
50
59
  if (this.field.min) {
51
60
  if ((values != null) && (values.length < this.field.min)) {
52
- return 'min';
61
+ return this.$t('apostrophe:minUi', { number: this.field.min });
53
62
  }
54
63
  }
55
64
  if (this.field.max) {
56
65
  if ((values != null) && (values.length > this.field.max)) {
57
- return 'max';
66
+ return this.$t('apostrophe:maxUi', { number: this.field.max });
58
67
  }
59
68
  }
60
69
 
@@ -69,6 +78,21 @@ export default {
69
78
  }
70
79
 
71
80
  return false;
81
+ },
82
+ selectItems(choice) {
83
+ if (choice.value === '__all') {
84
+ this.value.data = this.choices.length === this.value.data.length
85
+ ? []
86
+ : this.choices.map(({ value }) => value);
87
+
88
+ return;
89
+ }
90
+
91
+ if (this.value.data.includes(choice.value)) {
92
+ this.value.data = this.value.data.filter((val) => val !== choice.value);
93
+ } else {
94
+ this.value.data.push(choice.value);
95
+ }
72
96
  }
73
97
  }
74
98
  };
@@ -0,0 +1,381 @@
1
+ <template>
2
+ <div
3
+ class="apos-primary-scrollbar apos-input-wrapper"
4
+ aria-haspopup="menu"
5
+ :class="{'apos-input-wrapper--disabled': field.readOnly}"
6
+ >
7
+ <ul
8
+ ref="select"
9
+ role="button"
10
+ :aria-expanded="showedList.toString()"
11
+ :aria-controls="`${field._id}-combo`"
12
+ v-click-outside-element="closeList"
13
+ class="apos-input-wrapper apos-combo__select"
14
+ @click="toggleList"
15
+ :tabindex="field.readOnly ? null : 0"
16
+ @keydown.prevent.space="toggleList"
17
+ @keydown.prevent.up="toggleList"
18
+ @keydown.prevent.down="toggleList"
19
+ >
20
+ <li
21
+ class="apos-combo__selected"
22
+ v-for="checked in selectedItems"
23
+ :key="checked"
24
+ @click="selectOption(getSelectedOption(checked))"
25
+ >
26
+ {{ getSelectedOption(checked)?.label }}
27
+ <AposIndicator
28
+ icon="close-icon"
29
+ class="apos-combo__close-icon"
30
+ :icon-size="10"
31
+ />
32
+ </li>
33
+ </ul>
34
+ <AposIndicator
35
+ icon="menu-down-icon"
36
+ class="apos-input-icon"
37
+ :icon-size="20"
38
+ />
39
+ <ul
40
+ :id="`${field._id}-combo`"
41
+ ref="list"
42
+ role="menu"
43
+ class="apos-combo__list"
44
+ :class="{'apos-combo__list--showed': showedList}"
45
+ :style="{top: boxHeight + 'px'}"
46
+ tabindex="0"
47
+ @keydown.prevent.space="selectOption(options[focusedItemIndex])"
48
+ @keydown.prevent.enter="selectOption(options[focusedItemIndex])"
49
+ @keydown.prevent.arrow-down="focusListItem()"
50
+ @keydown.prevent.arrow-up="focusListItem(true)"
51
+ @keydown.prevent.delete="closeList(null, true)"
52
+ @keydown.prevent.stop.esc="closeList(null, true)"
53
+ @blur="closeList()"
54
+ >
55
+ <li
56
+ :key="choice.value"
57
+ class="apos-combo__list-item"
58
+ role="menuitemcheckbox"
59
+ :class="{focused: focusedItemIndex === i}"
60
+ v-for="(choice, i) in options"
61
+ @click.stop="selectOption(choice)"
62
+ @mouseover="focusedItemIndex = i"
63
+ >
64
+ <AposIndicator
65
+ v-if="isSelected(choice)"
66
+ icon="check-bold-icon"
67
+ class="apos-combo__check-icon"
68
+ :icon-size="10"
69
+ />
70
+ {{ choice.label }}
71
+ </li>
72
+ </ul>
73
+ </div>
74
+ </template>
75
+
76
+ <script>
77
+ export default {
78
+ name: 'AposCombo',
79
+ props: {
80
+ choices: {
81
+ type: Array,
82
+ required: true
83
+ },
84
+ field: {
85
+ type: Object,
86
+ required: true
87
+ },
88
+ value: {
89
+ type: Object,
90
+ required: true
91
+ }
92
+ },
93
+
94
+ emits: [ 'select-items' ],
95
+ data () {
96
+ const showSelectAll = this.field.all !== false &&
97
+ (!this.field.max || this.field.max > this.choices.length);
98
+
99
+ return {
100
+ showedList: false,
101
+ boxHeight: 0,
102
+ showSelectAll,
103
+ options: this.renderOptions(showSelectAll),
104
+ boxResizeObserver: this.getBoxResizeObserver(),
105
+ focusedItemIndex: null
106
+ };
107
+ },
108
+ computed: {
109
+ selectedItems() {
110
+ if (this.allItemsSelected()) {
111
+ return [ '__all' ];
112
+ }
113
+
114
+ return this.value.data;
115
+ }
116
+ },
117
+ mounted() {
118
+ this.boxResizeObserver.observe(this.$refs.select);
119
+ },
120
+ beforeDestroy() {
121
+ this.boxResizeObserver.unobserve(this.$refs.select);
122
+ },
123
+ methods: {
124
+ toggleList() {
125
+ if (this.field.readOnly) {
126
+ return;
127
+ }
128
+ this.showedList = !this.showedList;
129
+
130
+ if (this.showedList) {
131
+ this.$nextTick(() => {
132
+ this.$refs.list.focus();
133
+ this.focusedItemIndex = 0;
134
+ });
135
+ } else {
136
+ this.$refs.select.focus();
137
+ this.resetList();
138
+ }
139
+ },
140
+ closeList(_, focusSelect) {
141
+ this.showedList = false;
142
+ this.resetList();
143
+
144
+ if (focusSelect) {
145
+ this.$nextTick(() => {
146
+ this.$refs.select.focus();
147
+ });
148
+ }
149
+ },
150
+ resetList() {
151
+ this.focusedItemIndex = null;
152
+ this.$refs.list.scrollTo({ top: 0 });
153
+ },
154
+ getBoxResizeObserver() {
155
+ return new ResizeObserver(([ { target } ]) => {
156
+ if (target.offsetHeight !== this.boxHeight) {
157
+ this.boxHeight = target.offsetHeight;
158
+ }
159
+ });
160
+ },
161
+ renderOptions(showSelectAll) {
162
+ if (!showSelectAll) {
163
+ return this.choices;
164
+ }
165
+
166
+ const { listLabel } = this.getSelectAllLabel();
167
+
168
+ return [
169
+ {
170
+ label: listLabel,
171
+ value: '__all'
172
+ },
173
+ ...this.choices
174
+ ];
175
+ },
176
+ isSelected(choice) {
177
+ return this.value.data.some((val) => val === choice.value);
178
+ },
179
+ allItemsSelected () {
180
+ return this.choices.length && this.value.data.length === this.choices.length;
181
+ },
182
+ getSelectedOption(checked) {
183
+ if (checked === '__all') {
184
+ const { selectedLabel } = this.getSelectAllLabel();
185
+ return {
186
+ label: selectedLabel,
187
+ value: checked
188
+ };
189
+ }
190
+
191
+ return this.choices.find((choice) => choice.value === checked);
192
+ },
193
+ emitSelectItems(data) {
194
+ return new Promise((resolve) => {
195
+ this.$emit('select-items', data);
196
+ this.$nextTick(resolve);
197
+ });
198
+ },
199
+ async selectOption(choice) {
200
+ if (this.field.readOnly) {
201
+ return;
202
+ }
203
+
204
+ const selectedChoice = this.showSelectAll && choice === '__all'
205
+ ? this.getSelectedOption('__all')
206
+ : choice;
207
+
208
+ await this.emitSelectItems(selectedChoice);
209
+
210
+ if (this.showSelectAll) {
211
+ const { listLabel } = this.getSelectAllLabel();
212
+ this.options[0].label = listLabel;
213
+ }
214
+ },
215
+
216
+ getSelectAllLabel() {
217
+ const allSelected = this.allItemsSelected();
218
+ const defaultSelectAllListLabel = allSelected
219
+ ? this.$t('apostrophe:deselectAll')
220
+ : this.$t('apostrophe:selectAll');
221
+ const selectedLabel = this.$t('apostrophe:allSelected');
222
+
223
+ if (this.field?.all?.label) {
224
+ const selectAllLabel = this.$t(this.field.all.label);
225
+ return {
226
+ selectedLabel,
227
+ listLabel: allSelected ? defaultSelectAllListLabel : selectAllLabel
228
+ };
229
+ }
230
+
231
+ return {
232
+ selectedLabel,
233
+ listLabel: defaultSelectAllListLabel
234
+ };
235
+ },
236
+ focusListItem(prev = false) {
237
+ const destIndex = (i) => prev ? i - 1 : i + 1;
238
+ const fallback = prev ? this.options.length - 1 : 0;
239
+ if (this.focusedItemIndex == null) {
240
+ this.focusedItemIndex = fallback;
241
+ } else {
242
+ this.focusedItemIndex = this.options[destIndex(this.focusedItemIndex)]
243
+ ? destIndex(this.focusedItemIndex)
244
+ : fallback;
245
+ }
246
+
247
+ const itemHeight = this.$refs.list.querySelector('li')?.clientHeight || 32;
248
+ const focusedItemPos = itemHeight * this.focusedItemIndex;
249
+ const { clientHeight, scrollTop } = this.$refs.list;
250
+ const listVisibility = clientHeight + scrollTop;
251
+ const scrollTo = (top) => {
252
+ this.$refs.list.scrollTo({
253
+ top,
254
+ behavior: 'smooth'
255
+ });
256
+ };
257
+ if (focusedItemPos + itemHeight > listVisibility) {
258
+ scrollTo((focusedItemPos + itemHeight) - clientHeight);
259
+ } else if (focusedItemPos < (listVisibility - clientHeight)) {
260
+ scrollTo(focusedItemPos);
261
+ }
262
+ }
263
+ }
264
+ };
265
+ </script>
266
+
267
+ <style lang="scss" scoped>
268
+ .apos-combo__check-icon {
269
+ position: absolute;
270
+ left: 5px;
271
+ }
272
+
273
+ .apos-input-wrapper:focus {
274
+ .apos-combo__list {
275
+ display: block;
276
+ }
277
+ }
278
+
279
+ .apos-combo__select {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ background-color: var(--a-base-9);
283
+ padding: 10px 30px 10px 8px;
284
+ min-height: 26px;
285
+ border: 1px solid var(--a-base-8);
286
+ border-radius: var(--a-border-radius);
287
+ cursor: pointer;
288
+ list-style: none;
289
+
290
+ &:hover {
291
+ border-color: var(--a-base-2);
292
+ }
293
+
294
+ &:focus {
295
+ box-shadow: 0 0 3px var(--a-base-2);
296
+ border-color: var(--a-base-2);
297
+ background-color: var(--a-base-10);
298
+ outline: none;
299
+ }
300
+ }
301
+
302
+ .apos-input-wrapper--disabled {
303
+ .apos-combo__select {
304
+ color: var(--a-base-4);
305
+ background: var(--a-base-7);
306
+ border-color: var(--a-base-4);
307
+ cursor: not-allowed;
308
+
309
+ &:hover {
310
+ border-color: var(--a-base-4);
311
+ }
312
+ }
313
+
314
+ .apos-combo__selected {
315
+ background-color: var(--a-base-8);
316
+ border-color: var(--a-base-3);
317
+ opacity: 0.7;
318
+ }
319
+
320
+ .apos-input-icon {
321
+ opacity: 0.7;
322
+ }
323
+ }
324
+
325
+ .apos-combo__selected {
326
+ @include type-base;
327
+ display: flex;
328
+ align-items: center;
329
+ gap: 4px;
330
+ background-color: var(--a-white);
331
+ margin: 2px;
332
+ padding: 5px 8px;
333
+ border: 1px solid var(--a-base-8);
334
+ border-radius: var(--a-border-radius);
335
+
336
+ &:hover {
337
+ background-color: var(--a-base-8);
338
+ border-color: var(--a-base-3);
339
+ cursor: not-allowed;
340
+ }
341
+
342
+ ::v-deep .apos-indicator {
343
+ width: 10px;
344
+ height: 10px;
345
+ }
346
+ }
347
+
348
+ .apos-combo__list {
349
+ z-index: $z-index-manager-display;
350
+ position: absolute;
351
+ top: 44px;
352
+ left: 0;
353
+ display: none;
354
+ width: 100%;
355
+ list-style: none;
356
+ background-color: var(--a-white);
357
+ padding-left: 0;
358
+ margin: 0;
359
+ max-height: 300px;
360
+ overflow-y: auto;
361
+ box-shadow: 0 0 3px var(--a-base-2);
362
+ border-radius: var(--a-border-radius);
363
+ outline: none;
364
+
365
+ &--showed {
366
+ display: block;
367
+ }
368
+ }
369
+
370
+ .apos-combo__list-item {
371
+ @include type-base;
372
+
373
+ padding: 10px 10px 10px 20px;
374
+ cursor: pointer;
375
+
376
+ &.focused {
377
+ background-color: var(--a-base-9);
378
+ }
379
+ }
380
+
381
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apostrophe",
3
- "version": "3.43.0",
3
+ "version": "3.44.0",
4
4
  "description": "The Apostrophe Content Management System.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -72,6 +72,38 @@ describe('Pieces Public API', function() {
72
72
  }
73
73
  });
74
74
 
75
+ it('should not be able to retrieve a piece by id from the database without a public API projection as a guest', async function() {
76
+ await t.createUser(apos, 'guest');
77
+ const jar = await t.loginAs(apos, 'guest');
78
+ try {
79
+ await apos.http.get('/api/v1/thing', {
80
+ jar
81
+ });
82
+ // Bad, we expected a 404
83
+ assert(false);
84
+ } catch (e) {
85
+ assert(e.status === 404);
86
+ }
87
+ });
88
+
89
+ it('should be able to retrieve a piece by id from the database without a public API projection as a guest if guest API access is enabled', async function() {
90
+ let response;
91
+ try {
92
+ apos.modules.thing.options.guestApiAccess = true;
93
+ const jar = await t.loginAs(apos, 'guest');
94
+ response = await apos.http.get('/api/v1/thing', {
95
+ jar
96
+ });
97
+ } finally {
98
+ apos.modules.thing.options.guestApiAccess = false;
99
+ }
100
+ assert(response);
101
+ assert(response.results);
102
+ assert(response.results.length === 1);
103
+ assert(response.results[0].title === 'hello');
104
+ assert(response.results[0].foo === 'bar');
105
+ });
106
+
75
107
  it('should be able to anonymously retrieve a piece by id from the database with a public API projection', async function() {
76
108
  // Patch the option setting to simplify the test code
77
109
  apos.thing.options.publicApiProjection = {
package/test/pieces.js CHANGED
@@ -14,6 +14,10 @@ describe('Pieces', function() {
14
14
 
15
15
  this.timeout(t.timeout);
16
16
 
17
+ let editor;
18
+ let contributor;
19
+ let guest;
20
+
17
21
  before(async function() {
18
22
  apos = await t.create({
19
23
  root: module,
@@ -1996,36 +2000,6 @@ describe('Pieces', function() {
1996
2000
  });
1997
2001
 
1998
2002
  describe('field viewPermission|editPermission', function() {
1999
- const createUser = (role = 'admin') => ({
2000
- title = `test-${role}`,
2001
- username = `test-${role}`,
2002
- password = role
2003
- } = {}) => {
2004
- return apos.user.insert(
2005
- apos.task.getReq(),
2006
- {
2007
- ...apos.user.newInstance(),
2008
- title,
2009
- username,
2010
- password,
2011
- email: `${username}@admin.io`,
2012
- role
2013
- }
2014
- );
2015
- };
2016
- const loginAs = async (role) => {
2017
- const jar = apos.http.jar();
2018
- await apos.http.post('/api/v1/@apostrophecms/login/login', {
2019
- body: {
2020
- username: `test-${role}`,
2021
- password: role,
2022
- session: true
2023
- },
2024
- jar
2025
- });
2026
-
2027
- return jar;
2028
- };
2029
2003
 
2030
2004
  this.afterEach(async function() {
2031
2005
  await apos.doc.db.deleteMany({ email: /@admin.io$/ });
@@ -2058,8 +2032,8 @@ describe('Pieces', function() {
2058
2032
  });
2059
2033
 
2060
2034
  it('should be able to retrieve fields with viewPermission when having appropriate credentials on rest API', async function() {
2061
- await createUser('admin')();
2062
- const jar = await loginAs('admin');
2035
+ // admin user was inserted earlier
2036
+ const jar = await t.loginAs(apos, 'admin');
2063
2037
 
2064
2038
  const req = apos.task.getReq();
2065
2039
  const candidate = {
@@ -2089,10 +2063,8 @@ describe('Pieces', function() {
2089
2063
  });
2090
2064
 
2091
2065
  it('should be able to edit fields with editPermission when having appropriate credentials on rest API', async function() {
2092
- await createUser('admin')();
2093
- const jar = await loginAs('admin');
2094
-
2095
- const editor = await createUser('editor')();
2066
+ const jar = await t.loginAs(apos, 'admin');
2067
+ editor = await t.createUser(apos, 'editor');
2096
2068
 
2097
2069
  const req = apos.task.getReq();
2098
2070
  const candidate = {
@@ -2207,8 +2179,7 @@ describe('Pieces', function() {
2207
2179
  });
2208
2180
 
2209
2181
  it('should be able to retrieve fields with viewPermission when having appropriate credentials', async function() {
2210
- await createUser('editor')();
2211
- const jar = await loginAs('editor');
2182
+ const jar = await t.loginAs(apos, 'editor');
2212
2183
 
2213
2184
  const req = apos.task.getReq();
2214
2185
  const candidate = {
@@ -2238,8 +2209,7 @@ describe('Pieces', function() {
2238
2209
  });
2239
2210
 
2240
2211
  it('should be able to edit fields with editPermission when having appropriate credentials on rest API', async function() {
2241
- await createUser('editor')();
2242
- const jar = await loginAs('editor');
2212
+ const jar = await t.loginAs(apos, 'editor');
2243
2213
 
2244
2214
  const req = apos.task.getReq();
2245
2215
  const candidate = {
@@ -2339,8 +2309,8 @@ describe('Pieces', function() {
2339
2309
  });
2340
2310
 
2341
2311
  it('should be able to retrieve fields with viewPermission when having appropriate credentials', async function() {
2342
- await createUser('contributor')();
2343
- const jar = await loginAs('contributor');
2312
+ contributor = await t.createUser(apos, 'contributor');
2313
+ const jar = await t.loginAs(apos, 'contributor');
2344
2314
 
2345
2315
  const req = apos.task.getReq();
2346
2316
  const candidate = {
@@ -2370,8 +2340,7 @@ describe('Pieces', function() {
2370
2340
  });
2371
2341
 
2372
2342
  it('should be able to edit fields with editPermission when having appropriate credentials on rest API', async function() {
2373
- await createUser('contributor')();
2374
- const jar = await loginAs('contributor');
2343
+ const jar = await t.loginAs(apos, 'contributor');
2375
2344
 
2376
2345
  const req = apos.task.getReq();
2377
2346
  const candidate = {
@@ -2453,13 +2422,12 @@ describe('Pieces', function() {
2453
2422
  });
2454
2423
 
2455
2424
  it('should be able to edit with permission checks during prepareForStorage & updateCacheField handlers', async function() {
2456
- const contributor = await createUser('contributor')();
2457
- const jar = await loginAs('contributor');
2425
+ const jar = await t.loginAs(apos, 'contributor');
2458
2426
 
2459
- const editor = await createUser('editor')();
2460
- const guest = await createUser('guest')();
2427
+ guest = await t.createUser(apos, 'guest');
2461
2428
 
2462
2429
  const req = apos.task.getReq({ mode: 'draft' });
2430
+
2463
2431
  const candidate = {
2464
2432
  ...apos.modules.board.newInstance(),
2465
2433
  title: 'Icarus',
package/test-lib/util.js CHANGED
@@ -58,40 +58,77 @@ async function create(options) {
58
58
  return require('../index.js')(config);
59
59
  }
60
60
 
61
- // Create an admin user. By default the username and password are both 'admin'
62
- async function createAdmin(apos, { username, password } = {}) {
63
- const user = apos.user.newInstance();
64
- const name = username || 'admin';
61
+ // Create an admin user. Invokes createUser with the `admin` role.
62
+ // Accepts the same options.
65
63
 
66
- user.title = name;
67
- user.username = name;
68
- user.password = password || 'admin';
69
- user.email = `${name}@admin.io`;
70
- user.role = 'admin';
64
+ async function createAdmin(apos, {
65
+ username, password, title, email
66
+ } = {}) {
67
+ return createUser(apos, 'admin', {
68
+ username,
69
+ password,
70
+ title,
71
+ email
72
+ });
73
+ }
74
+
75
+ // Resolves to a user with the specified `role`. `username` defaults to
76
+ // `role`. `title` defaults to `username` or `role`.
77
+ // `password` defaults to `username` or `role`. `email` defaults to `${username}@test.io`
78
+ // where `username` can also be inferred from `role`.
79
+
80
+ async function createUser(apos, role, {
81
+ username, password, title, email
82
+ } = {}) {
83
+ title = title || username || role;
84
+ username = username || role;
85
+ password = password || username || role;
86
+ email = email || `${username}@test.io`;
71
87
 
72
- return await apos.user.insert(apos.task.getReq(), user);
88
+ return apos.user.insert(
89
+ apos.task.getReq(),
90
+ {
91
+ ...apos.user.newInstance(),
92
+ title,
93
+ username,
94
+ password,
95
+ email,
96
+ role
97
+ }
98
+ );
73
99
  }
74
100
 
75
- async function getUserJar(apos, { username, password } = {}) {
76
- const jar = apos.http.jar();
101
+ // Log in with the specified password. Returns a
102
+ // cookie jar object for use with additional
103
+ // apos.http calls via the `jar` option.
104
+ // `password` defaults to `username`.
77
105
 
78
- // Log in
106
+ async function loginAs(apos, username, password) {
107
+ password = password || username;
108
+ const jar = apos.http.jar();
79
109
  await apos.http.post('/api/v1/@apostrophecms/login/login', {
80
110
  body: {
81
- username: username || 'admin',
82
- password: password || 'admin',
111
+ username,
112
+ password,
83
113
  session: true
84
114
  },
85
115
  jar
86
116
  });
87
117
 
88
118
  return jar;
119
+ };
120
+
121
+ // Deprecated legacy wrapper for loginAs.
122
+ function getUserJar(apos, { username = 'admin', password } = {}) {
123
+ return loginAs(apos, username, password);
89
124
  }
90
125
 
91
126
  module.exports = {
92
127
  destroy,
93
128
  create,
94
129
  createAdmin,
130
+ createUser,
131
+ loginAs,
95
132
  getUserJar,
96
133
  timeout: (process.env.TEST_TIMEOUT && parseInt(process.env.TEST_TIMEOUT)) || 20000
97
134
  };