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

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 +504 -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 +571 -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 +1386 -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 +683 -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 +1628 -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,124 @@ 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
+ // TODO: Fix these being added by the drivers.
236
+ // @ts-ignore
237
+ delete this.$data.acRead;
238
+ // @ts-ignore
239
+ delete this.$data.acWrite;
240
+ // @ts-ignore
241
+ delete this.$data.acFull;
242
+ if (source === 'server') {
243
+ this.$originalGroupname = this.$data.groupname;
244
+ }
121
245
  this.$updateDataProtection();
122
246
  }
247
+ /**
248
+ * Update the data protection arrays for a user.
249
+ *
250
+ * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
251
+ */
123
252
  $updateDataProtection(givenUser) {
124
- const tilmeld = this.$nymph.tilmeld;
253
+ const tilmeld = enforceTilmeld(this);
125
254
  let user = givenUser ?? tilmeld.User.current();
255
+ let [_groupname, domain] = tilmeld.config.domainSupport
256
+ ? splitn(this.$data.groupname ?? '', '@', 2)
257
+ : [this.$data.groupname, null];
258
+ // Lowercase the domain part.
259
+ if (domain) {
260
+ domain = domain.toLowerCase();
261
+ }
126
262
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
127
263
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
128
264
  if (tilmeld.config.emailUsernames) {
129
265
  this.$privateData.push('groupname');
130
266
  }
131
267
  if (user != null &&
132
- ((user.abilities?.indexOf('tilmeld/admin') ?? -1) !== -1 ||
133
- (user.abilities?.indexOf('system/admin') ?? -1) !== -1)) {
268
+ (user.abilities?.includes('tilmeld/admin') ||
269
+ user.abilities?.includes('system/admin'))) {
270
+ // Users who can edit groups can see their data.
134
271
  this.$privateData = [];
135
272
  this.$allowlistData = undefined;
136
273
  }
137
274
  else if (this.$data.user != null &&
138
275
  user != null &&
139
- this.$data.user.$is(user)) {
276
+ this.$data.user.guid === user.guid) {
277
+ // Users can see their group's data.
278
+ this.$privateData = [];
279
+ }
280
+ else if (user != null &&
281
+ tilmeld.config.domainSupport &&
282
+ domain &&
283
+ user.abilities?.includes(`tilmeld/domain/${domain}/admin`)) {
284
+ // Domain admins can see their groups' data.
140
285
  this.$privateData = [];
141
286
  }
142
287
  }
143
- $isDescendant(givenGroup) {
144
- const tilmeld = this.$nymph.tilmeld;
288
+ /**
289
+ * Check whether the group is a descendant of a group.
290
+ *
291
+ * @param group The group, or the group's GUID.
292
+ * @returns True or false.
293
+ */
294
+ async $isDescendant(givenGroup) {
295
+ const tilmeld = enforceTilmeld(this);
145
296
  let group;
146
297
  if (typeof givenGroup === 'string') {
147
- group = tilmeld.Group.factorySync(givenGroup);
298
+ group = await tilmeld.Group.factory(givenGroup);
148
299
  }
149
300
  else {
150
301
  group = givenGroup;
@@ -152,27 +303,41 @@ class Group extends AbleObject_1.default {
152
303
  if (group.guid == null) {
153
304
  return false;
154
305
  }
155
- if (this.$data.parent == null) {
306
+ // Check to see if the group is a descendant of the given group.
307
+ const parent = this.$data.parent;
308
+ await parent?.$wake();
309
+ if (parent == null) {
156
310
  return false;
157
311
  }
158
- if (this.$data.parent.$is(group)) {
312
+ if (parent.$is(group)) {
159
313
  return true;
160
314
  }
161
- if (this.$data.parent.$isDescendant(group)) {
315
+ if (await parent.$isDescendant(group)) {
162
316
  return true;
163
317
  }
164
318
  return false;
165
319
  }
320
+ /**
321
+ * Gets an array of the group's child groups.
322
+ *
323
+ * @returns An array of groups.
324
+ */
166
325
  async $getChildren() {
167
- const tilmeld = this.$nymph.tilmeld;
326
+ const tilmeld = enforceTilmeld(this);
168
327
  return await this.$nymph.getEntities({ class: tilmeld.Group }, {
169
328
  type: '&',
170
329
  equal: ['enabled', true],
171
330
  ref: ['parent', this],
172
331
  });
173
332
  }
333
+ /**
334
+ * Gets an array of the group's descendant groups.
335
+ *
336
+ * @param andSelf Include this group in the returned array.
337
+ * @returns An array of groups.
338
+ */
174
339
  async $getDescendants(andSelf = false) {
175
- const tilmeld = this.$nymph.tilmeld;
340
+ const tilmeld = enforceTilmeld(this);
176
341
  let groups = [];
177
342
  const entities = await this.$nymph.getEntities({ class: tilmeld.Group }, {
178
343
  type: '&',
@@ -187,39 +352,41 @@ class Group extends AbleObject_1.default {
187
352
  }
188
353
  return groups;
189
354
  }
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);
355
+ /**
356
+ * Get the number of parents the group has.
357
+ *
358
+ * If the group is a top level group, this will return 0. If it is a child of
359
+ * a top level group, this will return 1. If it is a grandchild of a top level
360
+ * group, this will return 2, and so on.
361
+ *
362
+ * Levels will max out at 1024 to avoid recursive loops.
363
+ *
364
+ * @returns The level of the group.
365
+ */
366
+ async $getLevel() {
367
+ const tilmeld = enforceTilmeld(this);
368
+ let group = await tilmeld.Group.factory(this.guid ?? undefined);
369
+ let parent = group.parent;
370
+ await parent?.$wake();
214
371
  let level = 0;
215
- while (group.parent != null && group.parent.cdate != null && level < 1024) {
372
+ while (parent != null && parent.cdate != null && level < 1024) {
216
373
  level++;
217
- group = group.parent;
374
+ group = parent;
375
+ parent = group.parent;
376
+ await parent?.$wake();
218
377
  }
219
378
  return level;
220
379
  }
380
+ /**
381
+ * Gets an array of users in the group.
382
+ *
383
+ * @param descendants Include users in all descendant groups too.
384
+ * @param limit The limit for the query.
385
+ * @param offset The offset for the query.
386
+ * @returns An array of users.
387
+ */
221
388
  async $getUsers(descendants = false, limit, offset) {
222
- const tilmeld = this.$nymph.tilmeld;
389
+ const tilmeld = enforceTilmeld(this);
223
390
  let groups = [];
224
391
  if (descendants) {
225
392
  groups = await this.$getDescendants();
@@ -247,14 +414,37 @@ class Group extends AbleObject_1.default {
247
414
  ],
248
415
  });
249
416
  }
417
+ /**
418
+ * Check that a groupname is valid.
419
+ *
420
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
421
+ */
250
422
  async $checkGroupname() {
251
- const tilmeld = this.$nymph.tilmeld;
423
+ const tilmeld = enforceTilmeld(this);
252
424
  if (!tilmeld.config.emailUsernames) {
253
- if (this.$data.groupname == null || !this.$data.groupname.length) {
425
+ let [groupname, domain] = tilmeld.config.domainSupport
426
+ ? splitn(this.$data.groupname ?? '', '@', 2)
427
+ : [this.$data.groupname, null];
428
+ // Lowercase the domain part.
429
+ if (domain) {
430
+ domain = domain.toLowerCase();
431
+ this.$data.groupname = `${groupname}@${domain}`;
432
+ }
433
+ if (this.$data.groupname == null ||
434
+ !this.$data.groupname.length ||
435
+ groupname == null ||
436
+ !groupname.length) {
254
437
  return { result: false, message: 'Please specify a groupname.' };
255
438
  }
256
- if (tilmeld.config.maxUsernameLength > 0 &&
257
- this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
439
+ if (groupname.length < tilmeld.config.minUsernameLength) {
440
+ return {
441
+ result: false,
442
+ message: 'Groupnames must be at least ' +
443
+ tilmeld.config.minUsernameLength +
444
+ ' characters.',
445
+ };
446
+ }
447
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
258
448
  return {
259
449
  result: false,
260
450
  message: 'Groupnames must not exceed ' +
@@ -262,18 +452,34 @@ class Group extends AbleObject_1.default {
262
452
  ' characters.',
263
453
  };
264
454
  }
265
- if ((0, lodash_1.difference)(this.$data.groupname.split(''), tilmeld.config.validChars.split('')).length) {
455
+ if (difference(groupname.split(''), tilmeld.config.validChars.split(''))
456
+ .length) {
266
457
  return {
267
458
  result: false,
268
459
  message: tilmeld.config.validCharsNotice,
269
460
  };
270
461
  }
271
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
462
+ if (!tilmeld.config.validRegex.test(groupname)) {
272
463
  return {
273
464
  result: false,
274
465
  message: tilmeld.config.validRegexNotice,
275
466
  };
276
467
  }
468
+ if (domain != null) {
469
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
470
+ return {
471
+ result: false,
472
+ message: tilmeld.config.validDomainRegexNotice,
473
+ };
474
+ }
475
+ if (!this.$is(tilmeld.currentUser?.group) &&
476
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
477
+ return {
478
+ result: false,
479
+ message: "You don't have the authority to manage groups on this domain.",
480
+ };
481
+ }
482
+ }
277
483
  const selector = {
278
484
  type: '&',
279
485
  ilike: [
@@ -288,6 +494,17 @@ class Group extends AbleObject_1.default {
288
494
  if (test != null) {
289
495
  return { result: false, message: 'That groupname is taken.' };
290
496
  }
497
+ for (let callback of this.constructor
498
+ .checkGroupnameCallbacks) {
499
+ if (callback) {
500
+ const result = await callback(this, {
501
+ groupname: this.$data.groupname,
502
+ });
503
+ if (!result.result) {
504
+ return { message: 'Groupname is not available.', ...result };
505
+ }
506
+ }
507
+ }
291
508
  return {
292
509
  result: true,
293
510
  message: this.guid != null ? 'Groupname is valid.' : 'Groupname is available!',
@@ -298,8 +515,15 @@ class Group extends AbleObject_1.default {
298
515
  if (this.$data.groupname == null || !this.$data.groupname.length) {
299
516
  return { result: false, message: 'Please specify an email.' };
300
517
  }
301
- if (tilmeld.config.maxUsernameLength > 0 &&
302
- this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
518
+ if (this.$data.groupname.length < tilmeld.config.minUsernameLength) {
519
+ return {
520
+ result: false,
521
+ message: 'Emails must be at least ' +
522
+ tilmeld.config.minUsernameLength +
523
+ ' characters.',
524
+ };
525
+ }
526
+ if (this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
303
527
  return {
304
528
  result: false,
305
529
  message: 'Emails must not exceed ' +
@@ -310,8 +534,13 @@ class Group extends AbleObject_1.default {
310
534
  return await this.$checkEmail();
311
535
  }
312
536
  }
537
+ /**
538
+ * Check that an email is unique.
539
+ *
540
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
541
+ */
313
542
  async $checkEmail() {
314
- const tilmeld = this.$nymph.tilmeld;
543
+ const tilmeld = enforceTilmeld(this);
315
544
  if (this.$data.email === '') {
316
545
  return { result: true, message: '' };
317
546
  }
@@ -344,10 +573,42 @@ class Group extends AbleObject_1.default {
344
573
  };
345
574
  }
346
575
  async $save() {
347
- const tilmeld = this.$nymph.tilmeld;
576
+ let tilmeld = enforceTilmeld(this);
348
577
  if (this.$data.groupname == null || !this.$data.groupname.trim().length) {
349
578
  return false;
350
579
  }
580
+ // Lowercase the domain part.
581
+ if (tilmeld.config.domainSupport) {
582
+ const [groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
583
+ if (domain) {
584
+ this.$data.groupname = `${groupname}@${domain.toLowerCase()}`;
585
+ }
586
+ }
587
+ if (tilmeld.config.domainSupport) {
588
+ const [_groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
589
+ const [_originalGroupname, originalDomain] = splitn(this.$originalGroupname ?? '', '@', 2);
590
+ if (this.guid != null &&
591
+ ((originalDomain == null && domain != null) ||
592
+ (domain == null && originalDomain != null))) {
593
+ throw new AccessControlError("Groups can't switch to or from having a domain once created.");
594
+ }
595
+ if (!this.$skipAcWhenSaving &&
596
+ !tilmeld.gatekeeper('tilmeld/admin') &&
597
+ !tilmeld.gatekeeper('system/admin')) {
598
+ if (domain != null &&
599
+ !this.$is(tilmeld.currentUser) &&
600
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
601
+ throw new AccessControlError("You don't have the authority to modify groups on this domain.");
602
+ }
603
+ if (originalDomain != null &&
604
+ originalDomain !== domain &&
605
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
606
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))) {
607
+ throw new AccessControlError("You don't have the authority to modify groups on this domain.");
608
+ }
609
+ }
610
+ }
611
+ // Formatting.
351
612
  this.$data.groupname = this.$data.groupname.trim();
352
613
  if (tilmeld.config.emailUsernames) {
353
614
  this.$data.email = this.$data.groupname;
@@ -356,16 +617,19 @@ class Group extends AbleObject_1.default {
356
617
  this.$data.name = (this.$data.name ?? '').trim();
357
618
  this.$data.phone = (this.$data.phone ?? '').trim();
358
619
  this.$data.enabled = !!this.$data.enabled;
359
- if (this.$data.name === '') {
620
+ // Clear empty values.
621
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
360
622
  delete this.$data.name;
361
623
  }
362
- if (this.$data.email === '') {
624
+ if (this.$data.email === '' ||
625
+ !tilmeld.config.userFields.includes('email')) {
363
626
  delete this.$data.email;
364
627
  }
365
628
  if (this.$data.avatar === '') {
366
629
  delete this.$data.avatar;
367
630
  }
368
- if (this.$data.phone === '') {
631
+ if (this.$data.phone === '' ||
632
+ !tilmeld.config.userFields.includes('phone')) {
369
633
  delete this.$data.phone;
370
634
  }
371
635
  if (this.$data.parent == null) {
@@ -383,46 +647,102 @@ class Group extends AbleObject_1.default {
383
647
  if (!this.$data.unverifiedSecondary) {
384
648
  delete this.$data.unverifiedSecondary;
385
649
  }
386
- this.$data.abilities = (0, lodash_1.difference)(this.$data.abilities ?? [], [
387
- 'system/admin',
388
- 'tilmeld/admin',
389
- null,
390
- undefined,
391
- ]);
650
+ // Groups should never have 'system/admin', 'tilmeld/admin',
651
+ // 'tilmeld/switch', or domain admin abilities.
652
+ this.$data.abilities = (this.$data.abilities ?? []).filter((ability) => {
653
+ if (['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
654
+ -1) {
655
+ return false;
656
+ }
657
+ if (ability == null) {
658
+ return false;
659
+ }
660
+ if (ability.startsWith('tilmeld/domain/') && ability.endsWith('/admin')) {
661
+ return false;
662
+ }
663
+ return true;
664
+ });
665
+ // Verification.
392
666
  const unCheck = await this.$checkGroupname();
393
667
  if (!unCheck.result) {
394
- throw new errors_1.BadUsernameError(unCheck.message);
668
+ throw new BadUsernameError(unCheck.message);
395
669
  }
396
- if (!tilmeld.config.emailUsernames) {
670
+ if (!tilmeld.config.emailUsernames &&
671
+ tilmeld.config.userFields.includes('email')) {
397
672
  const emCheck = await this.$checkEmail();
398
673
  if (!emCheck.result) {
399
- throw new errors_1.BadEmailError(emCheck.message);
674
+ throw new BadEmailError(emCheck.message);
400
675
  }
401
676
  }
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.");
677
+ // Validate group parent. Make sure it's not a descendant of this group.
678
+ const parent = this.$data.parent;
679
+ await parent?.$wake();
680
+ if (parent &&
681
+ (parent.cdate == null ||
682
+ this.$is(parent) ||
683
+ (await parent.$isDescendant(this)))) {
684
+ throw new BadDataError("Group parent can't be itself or descendant of itself.");
407
685
  }
408
686
  try {
409
- tilmeld.config.validatorGroup(this);
687
+ tilmeld.config.validatorGroup(tilmeld, this);
410
688
  }
411
689
  catch (e) {
412
- throw new errors_1.BadDataError(e?.message);
690
+ throw new BadDataError(e?.message);
413
691
  }
692
+ // Only one default primary group is allowed.
414
693
  if (this.$data.defaultPrimary) {
415
694
  const currentPrimary = await this.$nymph.getEntity({ class: tilmeld.Group }, { type: '&', truthy: 'defaultPrimary' });
416
695
  if (currentPrimary != null && !this.$is(currentPrimary)) {
417
696
  currentPrimary.defaultPrimary = false;
418
- if (!(await currentPrimary.$save())) {
419
- throw new errors_1.CouldNotChangeDefaultPrimaryGroupError('Could not change new user primary group from ' +
697
+ if (this.$skipAcWhenSaving
698
+ ? !(await currentPrimary.$saveSkipAC())
699
+ : !(await currentPrimary.$save())) {
700
+ throw new CouldNotChangeDefaultPrimaryGroupError('Could not change new user primary group from ' +
420
701
  `${currentPrimary.groupname}.`);
421
702
  }
422
703
  }
423
704
  }
424
- return await super.$save();
705
+ const transaction = 'tilmeld-save-group-' + (this.guid || nanoid());
706
+ const nymph = this.$nymph;
707
+ const tnymph = await nymph.startTransaction(transaction);
708
+ this.$setNymph(tnymph);
709
+ tilmeld = enforceTilmeld(this);
710
+ let preGuid = this.guid;
711
+ let preCdate = this.cdate;
712
+ let preMdate = this.mdate;
713
+ try {
714
+ for (let callback of this.constructor
715
+ .beforeSaveCallbacks) {
716
+ if (callback) {
717
+ await callback(this);
718
+ }
719
+ }
720
+ let ret = await super.$save();
721
+ if (ret) {
722
+ this.$originalGroupname = this.$data.groupname;
723
+ }
724
+ for (let callback of this.constructor
725
+ .afterSaveCallbacks) {
726
+ if (callback) {
727
+ await callback(this);
728
+ }
729
+ }
730
+ ret = await tnymph.commit(transaction);
731
+ this.$setNymph(nymph);
732
+ return ret;
733
+ }
734
+ catch (e) {
735
+ await tnymph.rollback(transaction);
736
+ this.guid = preGuid;
737
+ this.cdate = preCdate;
738
+ this.mdate = preMdate;
739
+ this.$setNymph(nymph);
740
+ throw e;
741
+ }
425
742
  }
743
+ /*
744
+ * This should *never* be accessible on the client.
745
+ */
426
746
  async $saveSkipAC() {
427
747
  this.$skipAcWhenSaving = true;
428
748
  return await this.$save();
@@ -435,73 +755,133 @@ class Group extends AbleObject_1.default {
435
755
  return false;
436
756
  }
437
757
  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.");
758
+ let tilmeld = enforceTilmeld(this);
759
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
760
+ throw new AccessControlError("You don't have the authority to delete groups.");
441
761
  }
442
- const transaction = 'tilmeld-delete-' + this.guid;
762
+ if (this.$data.groupname !== this.$originalGroupname) {
763
+ throw new AccessControlError("You can't delete a group while switching its groupname.");
764
+ }
765
+ const transaction = 'tilmeld-delete-group-' + this.guid;
443
766
  const nymph = this.$nymph;
444
767
  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())) {
768
+ this.$setNymph(tnymph);
769
+ tilmeld = enforceTilmeld(this);
770
+ const User = tnymph.getEntityClass(UserClass);
771
+ try {
772
+ for (let callback of this.constructor
773
+ .beforeDeleteCallbacks) {
774
+ if (callback) {
775
+ await callback(this);
776
+ }
777
+ }
778
+ // Delete descendants.
779
+ const descendants = await this.$getDescendants();
780
+ if (descendants.length) {
781
+ for (let curGroup of descendants) {
782
+ if (this.$skipAcWhenDeleting
783
+ ? !(await curGroup.$deleteSkipAC())
784
+ : !(await curGroup.$delete())) {
785
+ await tnymph.rollback(transaction);
786
+ this.$setNymph(nymph);
787
+ return false;
788
+ }
789
+ }
790
+ }
791
+ // Remove users from this primary group.
792
+ const primaryUsers = await tnymph.getEntities({
793
+ class: User,
794
+ skipAc: true,
795
+ }, {
796
+ type: '&',
797
+ ref: ['group', this],
798
+ });
799
+ for (let user of primaryUsers) {
800
+ delete user.group;
801
+ if (this.$skipAcWhenDeleting
802
+ ? !(await user.$saveSkipAC())
803
+ : !(await user.$save())) {
451
804
  await tnymph.rollback(transaction);
452
- this.$nymph = nymph;
805
+ this.$setNymph(nymph);
453
806
  return false;
454
807
  }
455
808
  }
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;
809
+ // Remove users from this secondary group.
810
+ const secondaryUsers = await tnymph.getEntities({
811
+ class: User,
812
+ skipAc: true,
813
+ }, {
814
+ type: '&',
815
+ ref: ['groups', this],
816
+ });
817
+ for (let user of secondaryUsers) {
818
+ user.$delGroup(this);
819
+ if (this.$skipAcWhenDeleting
820
+ ? !(await user.$saveSkipAC())
821
+ : !(await user.$save())) {
822
+ await tnymph.rollback(transaction);
823
+ this.$setNymph(nymph);
824
+ return false;
825
+ }
470
826
  }
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())) {
827
+ // Delete the group.
828
+ let success = await super.$delete();
829
+ if (success) {
830
+ for (let callback of this.constructor
831
+ .afterDeleteCallbacks) {
832
+ if (callback) {
833
+ await callback(this);
834
+ }
835
+ }
836
+ success = await tnymph.commit(transaction);
837
+ }
838
+ else {
482
839
  await tnymph.rollback(transaction);
483
- return false;
484
840
  }
841
+ this.$setNymph(nymph);
842
+ return success;
485
843
  }
486
- const success = await super.$delete();
487
- if (success) {
488
- await tnymph.commit(transaction);
489
- }
490
- else {
844
+ catch (e) {
491
845
  await tnymph.rollback(transaction);
846
+ this.$setNymph(nymph);
847
+ throw e;
492
848
  }
493
- this.$nymph = nymph;
494
- return success;
849
+ }
850
+ /*
851
+ * This should *never* be accessible on the client.
852
+ */
853
+ async $deleteSkipAC() {
854
+ this.$skipAcWhenDeleting = true;
855
+ return await this.$delete();
856
+ }
857
+ $tilmeldDeleteSkipAC() {
858
+ if (this.$skipAcWhenDeleting) {
859
+ this.$skipAcWhenDeleting = false;
860
+ return true;
861
+ }
862
+ return false;
863
+ }
864
+ static on(event, callback) {
865
+ const prop = (event + 'Callbacks');
866
+ if (!(prop in this)) {
867
+ throw new Error('Invalid event type.');
868
+ }
869
+ // @ts-ignore: The callback should always be the right type here.
870
+ this[prop].push(callback);
871
+ return () => this.off(event, callback);
872
+ }
873
+ static off(event, callback) {
874
+ const prop = (event + 'Callbacks');
875
+ if (!(prop in this)) {
876
+ return false;
877
+ }
878
+ // @ts-ignore: The callback should always be the right type here.
879
+ const i = this[prop].indexOf(callback);
880
+ if (i > -1) {
881
+ // @ts-ignore: The callback should always be the right type here.
882
+ this[prop].splice(i, 1);
883
+ }
884
+ return true;
495
885
  }
496
886
  }
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
887
  //# sourceMappingURL=Group.js.map