@nymphjs/tilmeld 1.0.0-beta.9 → 1.0.0-beta.91

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