@nymphjs/tilmeld 1.0.0-beta.11 → 1.0.0-beta.110

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 (83) hide show
  1. package/CHANGELOG.md +498 -0
  2. package/README.md +15 -2
  3. package/dist/AbleObject.d.ts +15 -0
  4. package/dist/AbleObject.js +18 -6
  5. package/dist/AbleObject.js.map +1 -1
  6. package/dist/Group.d.ts +182 -15
  7. package/dist/Group.js +564 -191
  8. package/dist/Group.js.map +1 -1
  9. package/dist/Group.test.js +86 -13
  10. package/dist/Group.test.js.map +1 -1
  11. package/dist/Tilmeld.d.ts +241 -12
  12. package/dist/Tilmeld.js +657 -257
  13. package/dist/Tilmeld.js.map +1 -1
  14. package/dist/Tilmeld.types.d.ts +5 -5
  15. package/dist/Tilmeld.types.js +1 -2
  16. package/dist/User.d.ts +396 -22
  17. package/dist/User.js +1379 -367
  18. package/dist/User.js.map +1 -1
  19. package/dist/User.test.js +133 -13
  20. package/dist/User.test.js.map +1 -1
  21. package/dist/conf/d.d.ts +306 -7
  22. package/dist/conf/d.js +1 -2
  23. package/dist/conf/defaults.d.ts +1 -1
  24. package/dist/conf/defaults.js +196 -141
  25. package/dist/conf/defaults.js.map +1 -1
  26. package/dist/conf/index.d.ts +2 -2
  27. package/dist/conf/index.js +2 -8
  28. package/dist/conf/index.js.map +1 -1
  29. package/dist/enforceTilmeld.d.ts +3 -0
  30. package/dist/enforceTilmeld.js +18 -0
  31. package/dist/enforceTilmeld.js.map +1 -0
  32. package/dist/errors/AccessControlError.d.ts +2 -1
  33. package/dist/errors/AccessControlError.js +3 -6
  34. package/dist/errors/AccessControlError.js.map +1 -1
  35. package/dist/errors/BadDataError.d.ts +2 -1
  36. package/dist/errors/BadDataError.js +3 -6
  37. package/dist/errors/BadDataError.js.map +1 -1
  38. package/dist/errors/BadEmailError.d.ts +2 -1
  39. package/dist/errors/BadEmailError.js +3 -6
  40. package/dist/errors/BadEmailError.js.map +1 -1
  41. package/dist/errors/BadUsernameError.d.ts +2 -1
  42. package/dist/errors/BadUsernameError.js +3 -6
  43. package/dist/errors/BadUsernameError.js.map +1 -1
  44. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js +1 -5
  45. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js.map +1 -1
  46. package/dist/errors/EmailChangeRateLimitExceededError.js +1 -5
  47. package/dist/errors/EmailChangeRateLimitExceededError.js.map +1 -1
  48. package/dist/errors/index.d.ts +6 -6
  49. package/dist/errors/index.js +6 -22
  50. package/dist/errors/index.js.map +1 -1
  51. package/dist/index.d.ts +12 -10
  52. package/dist/index.js +17 -35
  53. package/dist/index.js.map +1 -1
  54. package/dist/testArtifacts.d.ts +19 -0
  55. package/dist/testArtifacts.js +920 -0
  56. package/dist/testArtifacts.js.map +1 -0
  57. package/dist/tilmeldJoiProps.d.ts +3 -3
  58. package/dist/tilmeldJoiProps.js +13 -18
  59. package/dist/tilmeldJoiProps.js.map +1 -1
  60. package/jest.config.js +11 -2
  61. package/package.json +32 -31
  62. package/src/AbleObject.ts +2 -2
  63. package/src/Group.test.ts +107 -6
  64. package/src/Group.ts +676 -224
  65. package/src/Tilmeld.ts +450 -262
  66. package/src/Tilmeld.types.ts +5 -5
  67. package/src/User.test.ts +163 -7
  68. package/src/User.ts +1621 -454
  69. package/src/conf/d.ts +83 -17
  70. package/src/conf/defaults.ts +172 -109
  71. package/src/conf/index.ts +2 -2
  72. package/src/enforceTilmeld.ts +20 -0
  73. package/src/errors/AccessControlError.ts +4 -2
  74. package/src/errors/BadDataError.ts +4 -2
  75. package/src/errors/BadEmailError.ts +4 -2
  76. package/src/errors/BadUsernameError.ts +4 -2
  77. package/src/errors/index.ts +6 -6
  78. package/src/index.ts +14 -10
  79. package/src/testArtifacts.ts +1485 -0
  80. package/src/tilmeldJoiProps.ts +8 -23
  81. package/src/type/locutus.d.ts +3 -0
  82. package/tsconfig.json +5 -3
  83. package/typedoc.json +4 -0
package/dist/Group.js CHANGED
@@ -1,15 +1,76 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const md5_1 = __importDefault(require("crypto-js/md5"));
7
- const lodash_1 = require("lodash");
8
- const AbleObject_1 = __importDefault(require("./AbleObject"));
9
- const errors_1 = require("./errors");
10
- class Group extends AbleObject_1.default {
11
- static async factory(guid) {
12
- return (await super.factory(guid));
1
+ import crypto from 'node:crypto';
2
+ import { nanoid } from '@nymphjs/guid';
3
+ import { difference, xor } from 'lodash-es';
4
+ import { splitn } from '@sciactive/splitn';
5
+ import { enforceTilmeld } from './enforceTilmeld.js';
6
+ import AbleObject from './AbleObject.js';
7
+ import { AccessControlError, BadDataError, BadEmailError, BadUsernameError, CouldNotChangeDefaultPrimaryGroupError, } from './errors/index.js';
8
+ import UserClass from './User.js';
9
+ /**
10
+ * A user group data model.
11
+ *
12
+ * Written by Hunter Perrin for SciActive.
13
+ *
14
+ * @author Hunter Perrin <hperrin@gmail.com>
15
+ * @copyright SciActive Inc
16
+ * @see http://nymph.io/
17
+ */
18
+ export default class Group extends AbleObject {
19
+ /**
20
+ * The instance of Tilmeld to use for queries.
21
+ */
22
+ static ETYPE = 'tilmeld_group';
23
+ static class = 'Group';
24
+ static checkGroupnameCallbacks = [];
25
+ static beforeSaveCallbacks = [];
26
+ static afterSaveCallbacks = [];
27
+ static beforeDeleteCallbacks = [];
28
+ static afterDeleteCallbacks = [];
29
+ static DEFAULT_PRIVATE_DATA = ['email', 'phone', 'abilities', 'user'];
30
+ static DEFAULT_ALLOWLIST_DATA = [];
31
+ $tags = [];
32
+ $clientEnabledMethods = [
33
+ '$checkGroupname',
34
+ '$checkEmail',
35
+ '$getAvatar',
36
+ '$getChildren',
37
+ '$getDescendants',
38
+ '$getLevel',
39
+ '$isDescendant',
40
+ ];
41
+ static clientEnabledStaticMethods = [
42
+ 'getPrimaryGroups',
43
+ 'getSecondaryGroups',
44
+ ];
45
+ $privateData = [...Group.DEFAULT_PRIVATE_DATA];
46
+ static searchRestrictedData = [...Group.DEFAULT_PRIVATE_DATA];
47
+ $allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
48
+ $allowlistTags = [];
49
+ /**
50
+ * This should only be used by the backend.
51
+ */
52
+ $skipAcWhenSaving = false;
53
+ /**
54
+ * This should only be used by the backend.
55
+ */
56
+ $skipAcWhenDeleting = false;
57
+ /**
58
+ * Used to save the current groupname for domain admin permissions.
59
+ */
60
+ $originalGroupname;
61
+ static getFTSText(name, value) {
62
+ if ([
63
+ 'abilities',
64
+ 'parent',
65
+ 'user',
66
+ 'enabled',
67
+ 'defaultPrimary',
68
+ 'defaultSecondary',
69
+ 'unverifiedSecondary',
70
+ ].indexOf(name) !== -1) {
71
+ return null;
72
+ }
73
+ return super.getFTSText(name, value);
13
74
  }
14
75
  static async factoryGroupname(groupname) {
15
76
  const entity = new this();
@@ -26,63 +87,89 @@ class Group extends AbleObject_1.default {
26
87
  }
27
88
  return entity;
28
89
  }
29
- static factorySync(guid) {
30
- return super.factorySync(guid);
31
- }
32
- constructor(guid) {
33
- super(guid);
34
- this.$tags = [];
35
- this.$clientEnabledMethods = [
36
- '$checkGroupname',
37
- '$checkEmail',
38
- '$getAvatar',
39
- '$getChildren',
40
- '$getDescendants',
41
- '$getLevel',
42
- '$isDescendant',
43
- ];
44
- this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
45
- this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
46
- this.$allowlistTags = [];
47
- this.$skipAcWhenSaving = false;
48
- if (this.guid == null) {
49
- this.$data.enabled = true;
50
- this.$data.abilities = [];
51
- this.$updateDataProtection();
52
- }
53
- }
90
+ /**
91
+ * Get all the groups that can be assigned as primary groups.
92
+ *
93
+ * @param options The options for an optional search query.
94
+ * @param selectors The selectors for an optional search query.
95
+ * @returns An array of the assignable primary groups.
96
+ */
54
97
  static async getPrimaryGroups(options, selectors) {
55
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
98
+ const tilmeld = enforceTilmeld(this);
99
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
56
100
  throw new Error("You don't have permission to do that.");
57
101
  }
58
- return await this.getAssignableGroups(this.tilmeld.config.highestPrimary, { ...options, class: this.tilmeld.Group, return: 'entity' }, [...(selectors ?? [])]);
102
+ return await this.getAssignableGroups(tilmeld.config.highestPrimary, { ...options, class: tilmeld.Group, return: 'entity' }, [...(selectors ?? [])]);
59
103
  }
104
+ /**
105
+ * Get all the groups that can be assigned as secondary groups.
106
+ *
107
+ * @param options The options for an optional search query.
108
+ * @param selectors The selectors for an optional search query.
109
+ * @returns An array of the assignable secondary groups.
110
+ */
60
111
  static async getSecondaryGroups(options, selectors) {
61
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
112
+ const tilmeld = enforceTilmeld(this);
113
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
62
114
  throw new Error("You don't have permission to do that.");
63
115
  }
64
- return await this.getAssignableGroups(this.tilmeld.config.highestSecondary, { ...options, class: this.tilmeld.Group, return: 'entity' }, [...(selectors ?? [])]);
116
+ return await this.getAssignableGroups(tilmeld.config.highestSecondary, { ...options, class: tilmeld.Group, return: 'entity' }, [...(selectors ?? [])]);
65
117
  }
66
118
  static async getAssignableGroups(highestParent, options, selectors) {
119
+ const tilmeld = enforceTilmeld(this);
67
120
  let assignableGroups = [];
68
121
  if (highestParent === false) {
69
122
  return assignableGroups;
70
123
  }
71
- assignableGroups = await this.nymph.getEntities({ ...options, class: this.tilmeld.Group }, ...selectors);
124
+ assignableGroups = await this.nymph.getEntities({ ...options, class: tilmeld.Group }, ...selectors);
72
125
  if (highestParent !== true) {
73
- assignableGroups = assignableGroups.filter((group) => {
126
+ assignableGroups = (await Promise.all(assignableGroups.map(async (group) => {
74
127
  let curGroup = group;
75
- while (curGroup.parent != null && curGroup.parent.cdate != null) {
76
- if (curGroup.parent.guid === highestParent) {
77
- return true;
128
+ let parent = curGroup.parent;
129
+ await parent?.$wake();
130
+ while (parent != null && parent?.cdate != null) {
131
+ if (parent?.guid === highestParent) {
132
+ return group;
78
133
  }
79
- curGroup = curGroup.parent;
134
+ curGroup = parent;
135
+ parent = curGroup.parent;
136
+ await parent?.$wake();
80
137
  }
81
- return false;
82
- });
138
+ return null;
139
+ }))).filter((group) => group != null);
83
140
  }
84
141
  return assignableGroups;
85
142
  }
143
+ constructor() {
144
+ super();
145
+ // Defaults.
146
+ this.$data.enabled = true;
147
+ this.$data.abilities = [];
148
+ this.$updateDataProtection();
149
+ }
150
+ $setNymph(nymph) {
151
+ this.$nymph = nymph;
152
+ if (!this.$asleep()) {
153
+ if (this.$data.user && this.$data.user.$nymph !== nymph) {
154
+ this.$data.user.$setNymph(nymph);
155
+ }
156
+ if (this.$data.parent && this.$data.parent.$nymph !== nymph) {
157
+ this.$data.parent.$setNymph(nymph);
158
+ }
159
+ }
160
+ }
161
+ async $getUniques() {
162
+ const tilmeld = enforceTilmeld(this);
163
+ const uniques = [`u:${this.$data.groupname}`];
164
+ if (!tilmeld.config.emailUsernames &&
165
+ tilmeld.config.userFields.includes('email')) {
166
+ uniques.push(`e:${this.$data.email}`);
167
+ }
168
+ if (this.$data.defaultPrimary) {
169
+ uniques.push(`o:default-primary`);
170
+ }
171
+ return uniques;
172
+ }
86
173
  $getAvatar() {
87
174
  if (this.$data.avatar != null && this.$data.avatar !== '') {
88
175
  return this.$data.avatar;
@@ -91,60 +178,117 @@ class Group extends AbleObject_1.default {
91
178
  return 'https://secure.gravatar.com/avatar/?d=mm&s=40';
92
179
  }
93
180
  return ('https://secure.gravatar.com/avatar/' +
94
- (0, md5_1.default)(this.$data.email.trim().toLowerCase()).toString() +
181
+ crypto
182
+ .createHash('sha256')
183
+ .update(this.$data.email.trim().toLowerCase())
184
+ .digest('hex')
185
+ .toLowerCase() +
95
186
  '?d=identicon&s=40');
96
187
  }
97
188
  $jsonAcceptData(input, allowConflict = false) {
98
- const tilmeld = this.$nymph.tilmeld;
99
- this.$referenceWake();
100
- if (input.data.abilities?.indexOf('system/admin') !== -1 &&
101
- this.$data.abilities?.indexOf('system/admin') === -1 &&
102
- tilmeld.gatekeeper('tilmeld/admin') &&
103
- !tilmeld.gatekeeper('system/admin')) {
104
- throw new errors_1.BadDataError("You don't have the authority to make this group a system admin.");
189
+ this.$check();
190
+ if ('abilities' in input.data) {
191
+ this.$checkIncomingAbilities(input.data.abilities);
105
192
  }
106
193
  super.$jsonAcceptData(input, allowConflict);
107
194
  }
108
195
  $jsonAcceptPatch(patch, allowConflict = false) {
109
- const tilmeld = this.$nymph.tilmeld;
110
- this.$referenceWake();
111
- if (patch.set.abilities?.indexOf('system/admin') !== -1 &&
112
- this.$data.abilities?.indexOf('system/admin') === -1 &&
113
- tilmeld.gatekeeper('tilmeld/admin') &&
114
- !tilmeld.gatekeeper('system/admin')) {
115
- throw new errors_1.BadDataError("You don't have the authority to make this group a system admin.");
196
+ this.$check();
197
+ if ('abilities' in patch.set) {
198
+ this.$checkIncomingAbilities(patch.set.abilities);
116
199
  }
117
200
  super.$jsonAcceptPatch(patch, allowConflict);
118
201
  }
119
- $putData(data, sdata) {
120
- super.$putData(data, sdata);
202
+ $checkIncomingAbilities(incoming) {
203
+ const tilmeld = enforceTilmeld(this);
204
+ if (!Array.isArray(incoming) ||
205
+ incoming.find((ability) => typeof ability !== 'string')) {
206
+ throw new BadDataError('Abilities must be an array of strings.');
207
+ }
208
+ if (incoming.includes('system/admin') !==
209
+ this.$data.abilities?.includes('system/admin') &&
210
+ (!tilmeld.currentUser ||
211
+ !tilmeld.currentUser.abilities?.includes('system/admin'))) {
212
+ throw new BadDataError("You don't have the authority to grant or revoke system/admin.");
213
+ }
214
+ if (incoming.includes('tilmeld/admin') !==
215
+ this.$data.abilities?.includes('tilmeld/admin') &&
216
+ (!tilmeld.currentUser ||
217
+ !tilmeld.currentUser.abilities?.includes('system/admin'))) {
218
+ throw new BadDataError("You don't have the authority to grant or revoke tilmeld/admin.");
219
+ }
220
+ if (incoming.includes('tilmeld/switch') !==
221
+ this.$data.abilities?.includes('tilmeld/switch') &&
222
+ (!tilmeld.currentUser ||
223
+ !tilmeld.currentUser.abilities?.includes('system/admin'))) {
224
+ throw new BadDataError("You don't have the authority to grant or revoke tilmeld/switch.");
225
+ }
226
+ if (xor(incoming.filter((ability) => ability.startsWith('tilmeld/')), this.$data.abilities?.filter((ability) => ability.startsWith('tilmeld/')) ?? []).length &&
227
+ (!tilmeld.currentUser ||
228
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
229
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))) {
230
+ throw new BadDataError("You don't have the authority to grant or revoke Tilmeld abilities.");
231
+ }
232
+ }
233
+ $putData(data, sdata, source) {
234
+ super.$putData(data, sdata, source);
235
+ if (source === 'server') {
236
+ this.$originalGroupname = this.$data.groupname;
237
+ }
121
238
  this.$updateDataProtection();
122
239
  }
240
+ /**
241
+ * Update the data protection arrays for a user.
242
+ *
243
+ * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
244
+ */
123
245
  $updateDataProtection(givenUser) {
124
- const tilmeld = this.$nymph.tilmeld;
246
+ const tilmeld = enforceTilmeld(this);
125
247
  let user = givenUser ?? tilmeld.User.current();
248
+ let [_groupname, domain] = tilmeld.config.domainSupport
249
+ ? splitn(this.$data.groupname ?? '', '@', 2)
250
+ : [this.$data.groupname, null];
251
+ // Lowercase the domain part.
252
+ if (domain) {
253
+ domain = domain.toLowerCase();
254
+ }
126
255
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
127
256
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
128
257
  if (tilmeld.config.emailUsernames) {
129
258
  this.$privateData.push('groupname');
130
259
  }
131
260
  if (user != null &&
132
- ((user.abilities?.indexOf('tilmeld/admin') ?? -1) !== -1 ||
133
- (user.abilities?.indexOf('system/admin') ?? -1) !== -1)) {
261
+ (user.abilities?.includes('tilmeld/admin') ||
262
+ user.abilities?.includes('system/admin'))) {
263
+ // Users who can edit groups can see their data.
134
264
  this.$privateData = [];
135
265
  this.$allowlistData = undefined;
136
266
  }
137
267
  else if (this.$data.user != null &&
138
268
  user != null &&
139
- this.$data.user.$is(user)) {
269
+ this.$data.user.guid === user.guid) {
270
+ // Users can see their group's data.
271
+ this.$privateData = [];
272
+ }
273
+ else if (user != null &&
274
+ tilmeld.config.domainSupport &&
275
+ domain &&
276
+ user.abilities?.includes(`tilmeld/domain/${domain}/admin`)) {
277
+ // Domain admins can see their groups' data.
140
278
  this.$privateData = [];
141
279
  }
142
280
  }
143
- $isDescendant(givenGroup) {
144
- const tilmeld = this.$nymph.tilmeld;
281
+ /**
282
+ * Check whether the group is a descendant of a group.
283
+ *
284
+ * @param group The group, or the group's GUID.
285
+ * @returns True or false.
286
+ */
287
+ async $isDescendant(givenGroup) {
288
+ const tilmeld = enforceTilmeld(this);
145
289
  let group;
146
290
  if (typeof givenGroup === 'string') {
147
- group = tilmeld.Group.factorySync(givenGroup);
291
+ group = await tilmeld.Group.factory(givenGroup);
148
292
  }
149
293
  else {
150
294
  group = givenGroup;
@@ -152,27 +296,41 @@ class Group extends AbleObject_1.default {
152
296
  if (group.guid == null) {
153
297
  return false;
154
298
  }
155
- if (this.$data.parent == null) {
299
+ // Check to see if the group is a descendant of the given group.
300
+ const parent = this.$data.parent;
301
+ await parent?.$wake();
302
+ if (parent == null) {
156
303
  return false;
157
304
  }
158
- if (this.$data.parent.$is(group)) {
305
+ if (parent.$is(group)) {
159
306
  return true;
160
307
  }
161
- if (this.$data.parent.$isDescendant(group)) {
308
+ if (await parent.$isDescendant(group)) {
162
309
  return true;
163
310
  }
164
311
  return false;
165
312
  }
313
+ /**
314
+ * Gets an array of the group's child groups.
315
+ *
316
+ * @returns An array of groups.
317
+ */
166
318
  async $getChildren() {
167
- const tilmeld = this.$nymph.tilmeld;
319
+ const tilmeld = enforceTilmeld(this);
168
320
  return await this.$nymph.getEntities({ class: tilmeld.Group }, {
169
321
  type: '&',
170
322
  equal: ['enabled', true],
171
323
  ref: ['parent', this],
172
324
  });
173
325
  }
326
+ /**
327
+ * Gets an array of the group's descendant groups.
328
+ *
329
+ * @param andSelf Include this group in the returned array.
330
+ * @returns An array of groups.
331
+ */
174
332
  async $getDescendants(andSelf = false) {
175
- const tilmeld = this.$nymph.tilmeld;
333
+ const tilmeld = enforceTilmeld(this);
176
334
  let groups = [];
177
335
  const entities = await this.$nymph.getEntities({ class: tilmeld.Group }, {
178
336
  type: '&',
@@ -187,39 +345,41 @@ class Group extends AbleObject_1.default {
187
345
  }
188
346
  return groups;
189
347
  }
190
- $getDescendantsSync(andSelf = false) {
191
- const tilmeld = this.$nymph.tilmeld;
192
- let groups = [];
193
- let entity;
194
- let offset = 0;
195
- do {
196
- entity = this.$nymph.driver.getEntitySync({ class: tilmeld.Group, offset }, {
197
- type: '&',
198
- equal: ['enabled', true],
199
- ref: ['parent', this],
200
- });
201
- if (entity != null) {
202
- groups = entity.$getDescendantsSync(true).concat(groups);
203
- }
204
- offset++;
205
- } while (entity != null);
206
- if (andSelf) {
207
- groups.push(this);
208
- }
209
- return groups;
210
- }
211
- $getLevel() {
212
- const tilmeld = this.$nymph.tilmeld;
213
- let group = tilmeld.Group.factorySync(this.guid ?? undefined);
348
+ /**
349
+ * Get the number of parents the group has.
350
+ *
351
+ * If the group is a top level group, this will return 0. If it is a child of
352
+ * a top level group, this will return 1. If it is a grandchild of a top level
353
+ * group, this will return 2, and so on.
354
+ *
355
+ * Levels will max out at 1024 to avoid recursive loops.
356
+ *
357
+ * @returns The level of the group.
358
+ */
359
+ async $getLevel() {
360
+ const tilmeld = enforceTilmeld(this);
361
+ let group = await tilmeld.Group.factory(this.guid ?? undefined);
362
+ let parent = group.parent;
363
+ await parent?.$wake();
214
364
  let level = 0;
215
- while (group.parent != null && group.parent.cdate != null && level < 1024) {
365
+ while (parent != null && parent.cdate != null && level < 1024) {
216
366
  level++;
217
- group = group.parent;
367
+ group = parent;
368
+ parent = group.parent;
369
+ await parent?.$wake();
218
370
  }
219
371
  return level;
220
372
  }
373
+ /**
374
+ * Gets an array of users in the group.
375
+ *
376
+ * @param descendants Include users in all descendant groups too.
377
+ * @param limit The limit for the query.
378
+ * @param offset The offset for the query.
379
+ * @returns An array of users.
380
+ */
221
381
  async $getUsers(descendants = false, limit, offset) {
222
- const tilmeld = this.$nymph.tilmeld;
382
+ const tilmeld = enforceTilmeld(this);
223
383
  let groups = [];
224
384
  if (descendants) {
225
385
  groups = await this.$getDescendants();
@@ -247,14 +407,37 @@ class Group extends AbleObject_1.default {
247
407
  ],
248
408
  });
249
409
  }
410
+ /**
411
+ * Check that a groupname is valid.
412
+ *
413
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
414
+ */
250
415
  async $checkGroupname() {
251
- const tilmeld = this.$nymph.tilmeld;
416
+ const tilmeld = enforceTilmeld(this);
252
417
  if (!tilmeld.config.emailUsernames) {
253
- if (this.$data.groupname == null || !this.$data.groupname.length) {
418
+ let [groupname, domain] = tilmeld.config.domainSupport
419
+ ? splitn(this.$data.groupname ?? '', '@', 2)
420
+ : [this.$data.groupname, null];
421
+ // Lowercase the domain part.
422
+ if (domain) {
423
+ domain = domain.toLowerCase();
424
+ this.$data.groupname = `${groupname}@${domain}`;
425
+ }
426
+ if (this.$data.groupname == null ||
427
+ !this.$data.groupname.length ||
428
+ groupname == null ||
429
+ !groupname.length) {
254
430
  return { result: false, message: 'Please specify a groupname.' };
255
431
  }
256
- if (tilmeld.config.maxUsernameLength > 0 &&
257
- this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
432
+ if (groupname.length < tilmeld.config.minUsernameLength) {
433
+ return {
434
+ result: false,
435
+ message: 'Groupnames must be at least ' +
436
+ tilmeld.config.minUsernameLength +
437
+ ' characters.',
438
+ };
439
+ }
440
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
258
441
  return {
259
442
  result: false,
260
443
  message: 'Groupnames must not exceed ' +
@@ -262,18 +445,34 @@ class Group extends AbleObject_1.default {
262
445
  ' characters.',
263
446
  };
264
447
  }
265
- if ((0, lodash_1.difference)(this.$data.groupname.split(''), tilmeld.config.validChars.split('')).length) {
448
+ if (difference(groupname.split(''), tilmeld.config.validChars.split(''))
449
+ .length) {
266
450
  return {
267
451
  result: false,
268
452
  message: tilmeld.config.validCharsNotice,
269
453
  };
270
454
  }
271
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
455
+ if (!tilmeld.config.validRegex.test(groupname)) {
272
456
  return {
273
457
  result: false,
274
458
  message: tilmeld.config.validRegexNotice,
275
459
  };
276
460
  }
461
+ if (domain != null) {
462
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
463
+ return {
464
+ result: false,
465
+ message: tilmeld.config.validDomainRegexNotice,
466
+ };
467
+ }
468
+ if (!this.$is(tilmeld.currentUser?.group) &&
469
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
470
+ return {
471
+ result: false,
472
+ message: "You don't have the authority to manage groups on this domain.",
473
+ };
474
+ }
475
+ }
277
476
  const selector = {
278
477
  type: '&',
279
478
  ilike: [
@@ -288,6 +487,17 @@ class Group extends AbleObject_1.default {
288
487
  if (test != null) {
289
488
  return { result: false, message: 'That groupname is taken.' };
290
489
  }
490
+ for (let callback of this.constructor
491
+ .checkGroupnameCallbacks) {
492
+ if (callback) {
493
+ const result = await callback(this, {
494
+ groupname: this.$data.groupname,
495
+ });
496
+ if (!result.result) {
497
+ return { message: 'Groupname is not available.', ...result };
498
+ }
499
+ }
500
+ }
291
501
  return {
292
502
  result: true,
293
503
  message: this.guid != null ? 'Groupname is valid.' : 'Groupname is available!',
@@ -298,8 +508,15 @@ class Group extends AbleObject_1.default {
298
508
  if (this.$data.groupname == null || !this.$data.groupname.length) {
299
509
  return { result: false, message: 'Please specify an email.' };
300
510
  }
301
- if (tilmeld.config.maxUsernameLength > 0 &&
302
- this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
511
+ if (this.$data.groupname.length < tilmeld.config.minUsernameLength) {
512
+ return {
513
+ result: false,
514
+ message: 'Emails must be at least ' +
515
+ tilmeld.config.minUsernameLength +
516
+ ' characters.',
517
+ };
518
+ }
519
+ if (this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
303
520
  return {
304
521
  result: false,
305
522
  message: 'Emails must not exceed ' +
@@ -310,8 +527,13 @@ class Group extends AbleObject_1.default {
310
527
  return await this.$checkEmail();
311
528
  }
312
529
  }
530
+ /**
531
+ * Check that an email is unique.
532
+ *
533
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
534
+ */
313
535
  async $checkEmail() {
314
- const tilmeld = this.$nymph.tilmeld;
536
+ const tilmeld = enforceTilmeld(this);
315
537
  if (this.$data.email === '') {
316
538
  return { result: true, message: '' };
317
539
  }
@@ -344,10 +566,42 @@ class Group extends AbleObject_1.default {
344
566
  };
345
567
  }
346
568
  async $save() {
347
- const tilmeld = this.$nymph.tilmeld;
569
+ let tilmeld = enforceTilmeld(this);
348
570
  if (this.$data.groupname == null || !this.$data.groupname.trim().length) {
349
571
  return false;
350
572
  }
573
+ // Lowercase the domain part.
574
+ if (tilmeld.config.domainSupport) {
575
+ const [groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
576
+ if (domain) {
577
+ this.$data.groupname = `${groupname}@${domain.toLowerCase()}`;
578
+ }
579
+ }
580
+ if (tilmeld.config.domainSupport) {
581
+ const [_groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
582
+ const [_originalGroupname, originalDomain] = splitn(this.$originalGroupname ?? '', '@', 2);
583
+ if (this.guid != null &&
584
+ ((originalDomain == null && domain != null) ||
585
+ (domain == null && originalDomain != null))) {
586
+ throw new AccessControlError("Groups can't switch to or from having a domain once created.");
587
+ }
588
+ if (!this.$skipAcWhenSaving &&
589
+ !tilmeld.gatekeeper('tilmeld/admin') &&
590
+ !tilmeld.gatekeeper('system/admin')) {
591
+ if (domain != null &&
592
+ !this.$is(tilmeld.currentUser) &&
593
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
594
+ throw new AccessControlError("You don't have the authority to modify groups on this domain.");
595
+ }
596
+ if (originalDomain != null &&
597
+ originalDomain !== domain &&
598
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
599
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))) {
600
+ throw new AccessControlError("You don't have the authority to modify groups on this domain.");
601
+ }
602
+ }
603
+ }
604
+ // Formatting.
351
605
  this.$data.groupname = this.$data.groupname.trim();
352
606
  if (tilmeld.config.emailUsernames) {
353
607
  this.$data.email = this.$data.groupname;
@@ -356,16 +610,19 @@ class Group extends AbleObject_1.default {
356
610
  this.$data.name = (this.$data.name ?? '').trim();
357
611
  this.$data.phone = (this.$data.phone ?? '').trim();
358
612
  this.$data.enabled = !!this.$data.enabled;
359
- if (this.$data.name === '') {
613
+ // Clear empty values.
614
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
360
615
  delete this.$data.name;
361
616
  }
362
- if (this.$data.email === '') {
617
+ if (this.$data.email === '' ||
618
+ !tilmeld.config.userFields.includes('email')) {
363
619
  delete this.$data.email;
364
620
  }
365
621
  if (this.$data.avatar === '') {
366
622
  delete this.$data.avatar;
367
623
  }
368
- if (this.$data.phone === '') {
624
+ if (this.$data.phone === '' ||
625
+ !tilmeld.config.userFields.includes('phone')) {
369
626
  delete this.$data.phone;
370
627
  }
371
628
  if (this.$data.parent == null) {
@@ -383,46 +640,102 @@ class Group extends AbleObject_1.default {
383
640
  if (!this.$data.unverifiedSecondary) {
384
641
  delete this.$data.unverifiedSecondary;
385
642
  }
386
- this.$data.abilities = (0, lodash_1.difference)(this.$data.abilities ?? [], [
387
- 'system/admin',
388
- 'tilmeld/admin',
389
- null,
390
- undefined,
391
- ]);
643
+ // Groups should never have 'system/admin', 'tilmeld/admin',
644
+ // 'tilmeld/switch', or domain admin abilities.
645
+ this.$data.abilities = (this.$data.abilities ?? []).filter((ability) => {
646
+ if (['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
647
+ -1) {
648
+ return false;
649
+ }
650
+ if (ability == null) {
651
+ return false;
652
+ }
653
+ if (ability.startsWith('tilmeld/domain/') && ability.endsWith('/admin')) {
654
+ return false;
655
+ }
656
+ return true;
657
+ });
658
+ // Verification.
392
659
  const unCheck = await this.$checkGroupname();
393
660
  if (!unCheck.result) {
394
- throw new errors_1.BadUsernameError(unCheck.message);
661
+ throw new BadUsernameError(unCheck.message);
395
662
  }
396
- if (!tilmeld.config.emailUsernames) {
663
+ if (!tilmeld.config.emailUsernames &&
664
+ tilmeld.config.userFields.includes('email')) {
397
665
  const emCheck = await this.$checkEmail();
398
666
  if (!emCheck.result) {
399
- throw new errors_1.BadEmailError(emCheck.message);
667
+ throw new BadEmailError(emCheck.message);
400
668
  }
401
669
  }
402
- if (this.$data.parent &&
403
- (this.$data.parent.cdate == null ||
404
- this.$is(this.$data.parent) ||
405
- this.$data.parent.$isDescendant(this))) {
406
- throw new errors_1.BadDataError("Group parent can't be itself or descendant of itself.");
670
+ // Validate group parent. Make sure it's not a descendant of this group.
671
+ const parent = this.$data.parent;
672
+ await parent?.$wake();
673
+ if (parent &&
674
+ (parent.cdate == null ||
675
+ this.$is(parent) ||
676
+ (await parent.$isDescendant(this)))) {
677
+ throw new BadDataError("Group parent can't be itself or descendant of itself.");
407
678
  }
408
679
  try {
409
- tilmeld.config.validatorGroup(this);
680
+ tilmeld.config.validatorGroup(tilmeld, this);
410
681
  }
411
682
  catch (e) {
412
- throw new errors_1.BadDataError(e?.message);
683
+ throw new BadDataError(e?.message);
413
684
  }
685
+ // Only one default primary group is allowed.
414
686
  if (this.$data.defaultPrimary) {
415
687
  const currentPrimary = await this.$nymph.getEntity({ class: tilmeld.Group }, { type: '&', truthy: 'defaultPrimary' });
416
688
  if (currentPrimary != null && !this.$is(currentPrimary)) {
417
689
  currentPrimary.defaultPrimary = false;
418
- if (!(await currentPrimary.$save())) {
419
- throw new errors_1.CouldNotChangeDefaultPrimaryGroupError('Could not change new user primary group from ' +
690
+ if (this.$skipAcWhenSaving
691
+ ? !(await currentPrimary.$saveSkipAC())
692
+ : !(await currentPrimary.$save())) {
693
+ throw new CouldNotChangeDefaultPrimaryGroupError('Could not change new user primary group from ' +
420
694
  `${currentPrimary.groupname}.`);
421
695
  }
422
696
  }
423
697
  }
424
- return await super.$save();
698
+ const transaction = 'tilmeld-save-group-' + (this.guid || nanoid());
699
+ const nymph = this.$nymph;
700
+ const tnymph = await nymph.startTransaction(transaction);
701
+ this.$setNymph(tnymph);
702
+ tilmeld = enforceTilmeld(this);
703
+ let preGuid = this.guid;
704
+ let preCdate = this.cdate;
705
+ let preMdate = this.mdate;
706
+ try {
707
+ for (let callback of this.constructor
708
+ .beforeSaveCallbacks) {
709
+ if (callback) {
710
+ await callback(this);
711
+ }
712
+ }
713
+ let ret = await super.$save();
714
+ if (ret) {
715
+ this.$originalGroupname = this.$data.groupname;
716
+ }
717
+ for (let callback of this.constructor
718
+ .afterSaveCallbacks) {
719
+ if (callback) {
720
+ await callback(this);
721
+ }
722
+ }
723
+ ret = await tnymph.commit(transaction);
724
+ this.$setNymph(nymph);
725
+ return ret;
726
+ }
727
+ catch (e) {
728
+ await tnymph.rollback(transaction);
729
+ this.guid = preGuid;
730
+ this.cdate = preCdate;
731
+ this.mdate = preMdate;
732
+ this.$setNymph(nymph);
733
+ throw e;
734
+ }
425
735
  }
736
+ /*
737
+ * This should *never* be accessible on the client.
738
+ */
426
739
  async $saveSkipAC() {
427
740
  this.$skipAcWhenSaving = true;
428
741
  return await this.$save();
@@ -435,73 +748,133 @@ class Group extends AbleObject_1.default {
435
748
  return false;
436
749
  }
437
750
  async $delete() {
438
- let tilmeld = this.$nymph.tilmeld;
439
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
440
- throw new errors_1.BadDataError("You don't have the authority to delete groups.");
751
+ let tilmeld = enforceTilmeld(this);
752
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
753
+ throw new AccessControlError("You don't have the authority to delete groups.");
441
754
  }
442
- const transaction = 'tilmeld-delete-' + this.guid;
755
+ if (this.$data.groupname !== this.$originalGroupname) {
756
+ throw new AccessControlError("You can't delete a group while switching its groupname.");
757
+ }
758
+ const transaction = 'tilmeld-delete-group-' + this.guid;
443
759
  const nymph = this.$nymph;
444
760
  const tnymph = await nymph.startTransaction(transaction);
445
- this.$nymph = tnymph;
446
- tilmeld = this.$nymph.tilmeld;
447
- const descendants = await this.$getDescendants();
448
- if (descendants.length) {
449
- for (let curGroup of descendants) {
450
- if (!(await curGroup.$delete())) {
761
+ this.$setNymph(tnymph);
762
+ tilmeld = enforceTilmeld(this);
763
+ const User = tnymph.getEntityClass(UserClass);
764
+ try {
765
+ for (let callback of this.constructor
766
+ .beforeDeleteCallbacks) {
767
+ if (callback) {
768
+ await callback(this);
769
+ }
770
+ }
771
+ // Delete descendants.
772
+ const descendants = await this.$getDescendants();
773
+ if (descendants.length) {
774
+ for (let curGroup of descendants) {
775
+ if (this.$skipAcWhenDeleting
776
+ ? !(await curGroup.$deleteSkipAC())
777
+ : !(await curGroup.$delete())) {
778
+ await tnymph.rollback(transaction);
779
+ this.$setNymph(nymph);
780
+ return false;
781
+ }
782
+ }
783
+ }
784
+ // Remove users from this primary group.
785
+ const primaryUsers = await tnymph.getEntities({
786
+ class: User,
787
+ skipAc: true,
788
+ }, {
789
+ type: '&',
790
+ ref: ['group', this],
791
+ });
792
+ for (let user of primaryUsers) {
793
+ delete user.group;
794
+ if (this.$skipAcWhenDeleting
795
+ ? !(await user.$saveSkipAC())
796
+ : !(await user.$save())) {
451
797
  await tnymph.rollback(transaction);
452
- this.$nymph = nymph;
798
+ this.$setNymph(nymph);
453
799
  return false;
454
800
  }
455
801
  }
456
- }
457
- const primaryUsers = await tnymph.getEntities({
458
- class: tilmeld.User,
459
- skipAc: true,
460
- }, {
461
- type: '&',
462
- ref: ['group', this],
463
- });
464
- for (let user of primaryUsers) {
465
- delete user.group;
466
- if (!(await user.$save())) {
467
- await tnymph.rollback(transaction);
468
- this.$nymph = nymph;
469
- return false;
802
+ // Remove users from this secondary group.
803
+ const secondaryUsers = await tnymph.getEntities({
804
+ class: User,
805
+ skipAc: true,
806
+ }, {
807
+ type: '&',
808
+ ref: ['groups', this],
809
+ });
810
+ for (let user of secondaryUsers) {
811
+ user.$delGroup(this);
812
+ if (this.$skipAcWhenDeleting
813
+ ? !(await user.$saveSkipAC())
814
+ : !(await user.$save())) {
815
+ await tnymph.rollback(transaction);
816
+ this.$setNymph(nymph);
817
+ return false;
818
+ }
470
819
  }
471
- }
472
- const secondaryUsers = await tnymph.getEntities({
473
- class: tilmeld.User,
474
- skipAc: true,
475
- }, {
476
- type: '&',
477
- ref: ['groups', this],
478
- });
479
- for (let user of secondaryUsers) {
480
- user.$delGroup(this);
481
- if (!(await user.$save())) {
820
+ // Delete the group.
821
+ let success = await super.$delete();
822
+ if (success) {
823
+ for (let callback of this.constructor
824
+ .afterDeleteCallbacks) {
825
+ if (callback) {
826
+ await callback(this);
827
+ }
828
+ }
829
+ success = await tnymph.commit(transaction);
830
+ }
831
+ else {
482
832
  await tnymph.rollback(transaction);
483
- return false;
484
833
  }
834
+ this.$setNymph(nymph);
835
+ return success;
485
836
  }
486
- const success = await super.$delete();
487
- if (success) {
488
- await tnymph.commit(transaction);
489
- }
490
- else {
837
+ catch (e) {
491
838
  await tnymph.rollback(transaction);
839
+ this.$setNymph(nymph);
840
+ throw e;
492
841
  }
493
- this.$nymph = nymph;
494
- return success;
842
+ }
843
+ /*
844
+ * This should *never* be accessible on the client.
845
+ */
846
+ async $deleteSkipAC() {
847
+ this.$skipAcWhenDeleting = true;
848
+ return await this.$delete();
849
+ }
850
+ $tilmeldDeleteSkipAC() {
851
+ if (this.$skipAcWhenDeleting) {
852
+ this.$skipAcWhenDeleting = false;
853
+ return true;
854
+ }
855
+ return false;
856
+ }
857
+ static on(event, callback) {
858
+ const prop = (event + 'Callbacks');
859
+ if (!(prop in this)) {
860
+ throw new Error('Invalid event type.');
861
+ }
862
+ // @ts-ignore: The callback should always be the right type here.
863
+ this[prop].push(callback);
864
+ return () => this.off(event, callback);
865
+ }
866
+ static off(event, callback) {
867
+ const prop = (event + 'Callbacks');
868
+ if (!(prop in this)) {
869
+ return false;
870
+ }
871
+ // @ts-ignore: The callback should always be the right type here.
872
+ const i = this[prop].indexOf(callback);
873
+ if (i > -1) {
874
+ // @ts-ignore: The callback should always be the right type here.
875
+ this[prop].splice(i, 1);
876
+ }
877
+ return true;
495
878
  }
496
879
  }
497
- exports.default = Group;
498
- Group.ETYPE = 'tilmeld_group';
499
- Group.class = 'Group';
500
- Group.DEFAULT_PRIVATE_DATA = ['email', 'phone', 'abilities', 'user'];
501
- Group.DEFAULT_ALLOWLIST_DATA = [];
502
- Group.clientEnabledStaticMethods = [
503
- 'getPrimaryGroups',
504
- 'getSecondaryGroups',
505
- ];
506
- Group.searchRestrictedData = [...Group.DEFAULT_PRIVATE_DATA];
507
880
  //# sourceMappingURL=Group.js.map