@nymphjs/tilmeld 1.0.0-beta.14 → 1.0.0-beta.140

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 (105) hide show
  1. package/CHANGELOG.md +668 -0
  2. package/README.md +21 -5
  3. package/dist/AbleObject.d.ts +20 -2
  4. package/dist/AbleObject.js +25 -13
  5. package/dist/AbleObject.js.map +1 -1
  6. package/dist/Group.d.ts +190 -27
  7. package/dist/Group.js +827 -440
  8. package/dist/Group.js.map +1 -1
  9. package/dist/Group.testArtifacts.d.ts +3 -0
  10. package/dist/Group.testArtifacts.js +120 -0
  11. package/dist/Group.testArtifacts.js.map +1 -0
  12. package/dist/Tilmeld.d.ts +275 -14
  13. package/dist/Tilmeld.js +789 -310
  14. package/dist/Tilmeld.js.map +1 -1
  15. package/dist/Tilmeld.types.d.ts +9 -9
  16. package/dist/Tilmeld.types.js +1 -2
  17. package/dist/User.d.ts +410 -41
  18. package/dist/User.js +2042 -1057
  19. package/dist/User.js.map +1 -1
  20. package/dist/User.testArtifacts.d.ts +3 -0
  21. package/dist/User.testArtifacts.js +175 -0
  22. package/dist/User.testArtifacts.js.map +1 -0
  23. package/dist/conf/defaults.d.ts +1 -1
  24. package/dist/conf/defaults.js +215 -141
  25. package/dist/conf/defaults.js.map +1 -1
  26. package/dist/conf/index.d.ts +361 -2
  27. package/dist/conf/index.js +2 -8
  28. package/dist/conf/index.js.map +1 -1
  29. package/dist/decorators/index.d.ts +2 -0
  30. package/dist/decorators/index.js +3 -0
  31. package/dist/decorators/index.js.map +1 -0
  32. package/dist/decorators/requireAbility.d.ts +10 -0
  33. package/dist/decorators/requireAbility.js +21 -0
  34. package/dist/decorators/requireAbility.js.map +1 -0
  35. package/dist/decorators/requireAuthentication.d.ts +8 -0
  36. package/dist/decorators/requireAuthentication.js +16 -0
  37. package/dist/decorators/requireAuthentication.js.map +1 -0
  38. package/dist/enforceTilmeld.d.ts +3 -0
  39. package/dist/enforceTilmeld.js +18 -0
  40. package/dist/enforceTilmeld.js.map +1 -0
  41. package/dist/errors/AccessControlError.d.ts +2 -1
  42. package/dist/errors/AccessControlError.js +3 -6
  43. package/dist/errors/AccessControlError.js.map +1 -1
  44. package/dist/errors/BadDataError.d.ts +2 -1
  45. package/dist/errors/BadDataError.js +3 -6
  46. package/dist/errors/BadDataError.js.map +1 -1
  47. package/dist/errors/BadEmailError.d.ts +2 -1
  48. package/dist/errors/BadEmailError.js +3 -6
  49. package/dist/errors/BadEmailError.js.map +1 -1
  50. package/dist/errors/BadUsernameError.d.ts +2 -1
  51. package/dist/errors/BadUsernameError.js +3 -6
  52. package/dist/errors/BadUsernameError.js.map +1 -1
  53. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js +1 -5
  54. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js.map +1 -1
  55. package/dist/errors/EmailChangeRateLimitExceededError.js +1 -5
  56. package/dist/errors/EmailChangeRateLimitExceededError.js.map +1 -1
  57. package/dist/errors/index.d.ts +6 -6
  58. package/dist/errors/index.js +6 -22
  59. package/dist/errors/index.js.map +1 -1
  60. package/dist/index.d.ts +10 -14
  61. package/dist/index.js +11 -35
  62. package/dist/index.js.map +1 -1
  63. package/dist/testArtifacts.d.ts +26 -0
  64. package/dist/testArtifacts.js +1346 -0
  65. package/dist/testArtifacts.js.map +1 -0
  66. package/dist/tilmeldJoiProps.d.ts +4 -5
  67. package/dist/tilmeldJoiProps.js +13 -18
  68. package/dist/tilmeldJoiProps.js.map +1 -1
  69. package/jest.config.js +31 -5
  70. package/package.json +49 -36
  71. package/src/AbleObject.ts +17 -11
  72. package/src/Group.testArtifacts.ts +159 -0
  73. package/src/Group.ts +712 -299
  74. package/src/Tilmeld.ts +666 -344
  75. package/src/Tilmeld.types.ts +9 -9
  76. package/src/User.testArtifacts.ts +226 -0
  77. package/src/User.ts +1984 -879
  78. package/src/conf/defaults.ts +205 -109
  79. package/src/conf/index.ts +381 -2
  80. package/src/decorators/index.ts +2 -0
  81. package/src/decorators/requireAbility.ts +26 -0
  82. package/src/decorators/requireAuthentication.ts +19 -0
  83. package/src/enforceTilmeld.ts +20 -0
  84. package/src/errors/AccessControlError.ts +4 -2
  85. package/src/errors/BadDataError.ts +4 -2
  86. package/src/errors/BadEmailError.ts +4 -2
  87. package/src/errors/BadUsernameError.ts +4 -2
  88. package/src/errors/index.ts +6 -6
  89. package/src/index.ts +11 -14
  90. package/src/testArtifacts.ts +2148 -0
  91. package/src/tilmeldJoiProps.ts +9 -24
  92. package/tsconfig.json +6 -3
  93. package/typedoc.json +4 -0
  94. package/dist/Group.test.d.ts +0 -1
  95. package/dist/Group.test.js +0 -48
  96. package/dist/Group.test.js.map +0 -1
  97. package/dist/User.test.d.ts +0 -1
  98. package/dist/User.test.js +0 -51
  99. package/dist/User.test.js.map +0 -1
  100. package/dist/conf/d.d.ts +0 -55
  101. package/dist/conf/d.js +0 -3
  102. package/dist/conf/d.js.map +0 -1
  103. package/src/Group.test.ts +0 -58
  104. package/src/User.test.ts +0 -61
  105. package/src/conf/d.ts +0 -303
package/src/Group.ts CHANGED
@@ -1,26 +1,68 @@
1
+ import crypto from 'node:crypto';
1
2
  import {
2
- EntityData,
3
- EntityInterface,
4
- EntityJson,
5
- EntityPatch,
6
- Options,
7
- Selector,
8
- SerializedEntityData,
3
+ clearSearchRestrictedData,
4
+ type EntityJson,
5
+ type EntityPatch,
6
+ type Options,
7
+ type Selector,
8
+ type SerializedEntityData,
9
+ transaction,
10
+ transactional,
9
11
  } from '@nymphjs/nymph';
10
- import md5 from 'crypto-js/md5';
11
- import { difference } from 'lodash';
12
+ import { nanoid } from '@nymphjs/guid';
13
+ import { difference, xor } from 'lodash-es';
14
+ import { splitn } from '@sciactive/splitn';
12
15
 
13
- import type Tilmeld from './Tilmeld';
14
- import AbleObject from './AbleObject';
16
+ import { enforceTilmeld } from './enforceTilmeld.js';
17
+ import { AbleObject } from './AbleObject.js';
15
18
  import {
19
+ AccessControlError,
16
20
  BadDataError,
17
21
  BadEmailError,
18
22
  BadUsernameError,
19
23
  CouldNotChangeDefaultPrimaryGroupError,
20
- } from './errors';
21
- import User, { UserData } from './User';
24
+ } from './errors/index.js';
25
+ import { User as UserClass } from './User.js';
22
26
 
23
- export type GroupData = {
27
+ export type GroupEventType =
28
+ | 'checkGroupname'
29
+ | 'beforeSave'
30
+ | 'afterSave'
31
+ | 'beforeDelete'
32
+ | 'afterDelete';
33
+ /**
34
+ * This is run when the user has entered an otherwise valid groupname into the
35
+ * signup form. It should return a result, which when false will stop the
36
+ * process and return the included message, disallowing the groupname.
37
+ */
38
+ export type TilmeldCheckGroupnameCallback = (
39
+ group: Group,
40
+ data: { groupname: string },
41
+ ) => Promise<{ result: boolean; message?: string }>;
42
+ /**
43
+ * This is run just before the group is saved, so all checks have passed.
44
+ *
45
+ * The transaction is available in `group.$nymph`.
46
+ */
47
+ export type TilmeldBeforeGroupSaveCallback = (group: Group) => Promise<void>;
48
+ /**
49
+ * This is run before the transaction is committed, and you can perform
50
+ * additional functions on the transaction, which is available in
51
+ * `group.$nymph`.
52
+ */
53
+ export type TilmeldAfterGroupSaveCallback = (group: Group) => Promise<void>;
54
+ /**
55
+ * The transaction is available in `group.$nymph`.
56
+ */
57
+ export type TilmeldBeforeGroupDeleteCallback = (group: Group) => Promise<void>;
58
+ /**
59
+ * This is run before the transaction is committed, and you can perform
60
+ * additional functions on the transaction, which is available in
61
+ * `group.$nymph`.
62
+ */
63
+ export type TilmeldAfterGroupDeleteCallback = (group: Group) => Promise<void>;
64
+
65
+ export interface GroupData {
24
66
  /**
25
67
  * The abilities granted to the group.
26
68
  */
@@ -48,11 +90,11 @@ export type GroupData = {
48
90
  /**
49
91
  * The group's parent.
50
92
  */
51
- parent?: Group & GroupData;
93
+ parent?: Group;
52
94
  /**
53
95
  * If generatePrimary is on, this will be the user who generated this group.
54
96
  */
55
- user?: (User & UserData) | null;
97
+ user?: UserClass | null;
56
98
 
57
99
  /**
58
100
  * Whether the group can be used.
@@ -70,24 +112,27 @@ export type GroupData = {
70
112
  * Whether this group is a default secondary group for unverified users.
71
113
  */
72
114
  unverifiedSecondary?: boolean;
73
- };
115
+ }
116
+
117
+ export interface Group extends GroupData {}
74
118
 
75
119
  /**
76
120
  * A user group data model.
77
121
  *
78
122
  * Written by Hunter Perrin for SciActive.
79
123
  *
80
- * @author Hunter Perrin <hperrin@gmail.com>
124
+ * @author Hunter Perrin <hperrin@port87.com>
81
125
  * @copyright SciActive Inc
82
126
  * @see http://nymph.io/
83
127
  */
84
- export default class Group extends AbleObject<GroupData> {
85
- /**
86
- * The instance of Tilmeld to use for queries.
87
- */
88
- static tilmeld: Tilmeld;
128
+ export class Group extends AbleObject<GroupData> {
89
129
  static ETYPE = 'tilmeld_group';
90
130
  static class = 'Group';
131
+ private static checkGroupnameCallbacks: TilmeldCheckGroupnameCallback[] = [];
132
+ private static beforeSaveCallbacks: TilmeldBeforeGroupSaveCallback[] = [];
133
+ private static afterSaveCallbacks: TilmeldAfterGroupSaveCallback[] = [];
134
+ private static beforeDeleteCallbacks: TilmeldBeforeGroupDeleteCallback[] = [];
135
+ private static afterDeleteCallbacks: TilmeldAfterGroupDeleteCallback[] = [];
91
136
 
92
137
  private static DEFAULT_PRIVATE_DATA = ['email', 'phone', 'abilities', 'user'];
93
138
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
@@ -112,17 +157,36 @@ export default class Group extends AbleObject<GroupData> {
112
157
  protected $allowlistTags?: string[] = [];
113
158
 
114
159
  /**
115
- * This is explicitly used only during the registration proccess.
160
+ * This should only be used by the backend.
116
161
  */
117
162
  private $skipAcWhenSaving = false;
163
+ /**
164
+ * This should only be used by the backend.
165
+ */
166
+ private $skipAcWhenDeleting = false;
167
+ /**
168
+ * Used to save the current groupname for domain admin permissions.
169
+ */
170
+ public $originalGroupname?: string;
118
171
 
119
- static async factory(guid?: string): Promise<Group & GroupData> {
120
- return (await super.factory(guid)) as Group & GroupData;
172
+ public static getFTSText(name: string, value: any) {
173
+ if (
174
+ [
175
+ 'abilities',
176
+ 'parent',
177
+ 'user',
178
+ 'enabled',
179
+ 'defaultPrimary',
180
+ 'defaultSecondary',
181
+ 'unverifiedSecondary',
182
+ ].indexOf(name) !== -1
183
+ ) {
184
+ return null;
185
+ }
186
+ return super.getFTSText(name, value);
121
187
  }
122
188
 
123
- static async factoryGroupname(
124
- groupname?: string
125
- ): Promise<Group & GroupData> {
189
+ static async factoryGroupname(groupname?: string): Promise<Group> {
126
190
  const entity = new this();
127
191
  if (groupname != null) {
128
192
  const entity = await this.nymph.getEntity(
@@ -132,7 +196,7 @@ export default class Group extends AbleObject<GroupData> {
132
196
  {
133
197
  type: '&',
134
198
  ilike: ['groupname', groupname.replace(/([\\%_])/g, (s) => `\\${s}`)],
135
- }
199
+ },
136
200
  );
137
201
  if (entity != null) {
138
202
  return entity;
@@ -141,21 +205,6 @@ export default class Group extends AbleObject<GroupData> {
141
205
  return entity;
142
206
  }
143
207
 
144
- static factorySync(guid?: string): Group & GroupData {
145
- return super.factorySync(guid) as Group & GroupData;
146
- }
147
-
148
- constructor(guid?: string) {
149
- super(guid);
150
-
151
- if (this.guid == null) {
152
- // Defaults.
153
- this.$data.enabled = true;
154
- this.$data.abilities = [];
155
- this.$updateDataProtection();
156
- }
157
- }
158
-
159
208
  /**
160
209
  * Get all the groups that can be assigned as primary groups.
161
210
  *
@@ -165,16 +214,17 @@ export default class Group extends AbleObject<GroupData> {
165
214
  */
166
215
  public static async getPrimaryGroups(
167
216
  options?: Options,
168
- selectors?: Selector[]
217
+ selectors?: Selector[],
169
218
  ) {
170
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
219
+ const tilmeld = enforceTilmeld(this);
220
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
171
221
  throw new Error("You don't have permission to do that.");
172
222
  }
173
223
 
174
224
  return await this.getAssignableGroups(
175
- this.tilmeld.config.highestPrimary,
176
- { ...options, class: this.tilmeld.Group, return: 'entity' },
177
- [...(selectors ?? [])]
225
+ tilmeld.config.highestPrimary,
226
+ { ...options, class: tilmeld.Group, return: 'entity' },
227
+ [...(selectors ?? [])],
178
228
  );
179
229
  }
180
230
 
@@ -187,112 +237,247 @@ export default class Group extends AbleObject<GroupData> {
187
237
  */
188
238
  public static async getSecondaryGroups(
189
239
  options?: Options,
190
- selectors?: Selector[]
240
+ selectors?: Selector[],
191
241
  ) {
192
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
242
+ const tilmeld = enforceTilmeld(this);
243
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
193
244
  throw new Error("You don't have permission to do that.");
194
245
  }
195
246
 
196
247
  return await this.getAssignableGroups(
197
- this.tilmeld.config.highestSecondary,
198
- { ...options, class: this.tilmeld.Group, return: 'entity' },
199
- [...(selectors ?? [])]
248
+ tilmeld.config.highestSecondary,
249
+ { ...options, class: tilmeld.Group, return: 'entity' },
250
+ [...(selectors ?? [])],
200
251
  );
201
252
  }
202
253
 
203
254
  private static async getAssignableGroups(
204
255
  highestParent: string | boolean,
205
256
  options: Options<typeof Group>,
206
- selectors: Selector[]
257
+ selectors: Selector[],
207
258
  ) {
208
- let assignableGroups: (Group & GroupData)[] = [];
259
+ const tilmeld = enforceTilmeld(this);
260
+ let assignableGroups: Group[] = [];
209
261
 
210
262
  if (highestParent === false) {
211
263
  return assignableGroups;
212
264
  }
213
265
 
266
+ ({ options, selectors } = clearSearchRestrictedData({
267
+ options,
268
+ selectors,
269
+ }));
270
+
214
271
  assignableGroups = await this.nymph.getEntities(
215
- { ...options, class: this.tilmeld.Group },
216
- ...selectors
272
+ { ...options, class: tilmeld.Group },
273
+ ...selectors,
217
274
  );
218
275
  if (highestParent !== true) {
219
- assignableGroups = assignableGroups.filter((group) => {
220
- let curGroup = group;
221
- while (curGroup.parent != null && curGroup.parent.cdate != null) {
222
- if (curGroup.parent.guid === highestParent) {
223
- return true;
224
- }
225
- curGroup = curGroup.parent;
226
- }
227
- return false;
228
- });
276
+ assignableGroups = (
277
+ await Promise.all(
278
+ assignableGroups.map(async (group) => {
279
+ let curGroup = group;
280
+ let parent = curGroup.parent;
281
+ await parent?.$wake();
282
+ while (parent != null && parent?.cdate != null) {
283
+ if (parent?.guid === highestParent) {
284
+ return group;
285
+ }
286
+ curGroup = parent;
287
+ parent = curGroup.parent;
288
+ await parent?.$wake();
289
+ }
290
+ return null;
291
+ }),
292
+ )
293
+ ).filter((group) => group != null) as Group[];
229
294
  }
230
295
  return assignableGroups;
231
296
  }
232
297
 
233
- public $getAvatar() {
234
- if (this.$data.avatar != null && this.$data.avatar !== '') {
235
- return this.$data.avatar;
298
+ constructor() {
299
+ super();
300
+
301
+ // Defaults.
302
+ this.enabled = true;
303
+ this.abilities = [];
304
+ this.$updateDataProtection();
305
+ }
306
+
307
+ async $getUniques(): Promise<string[]> {
308
+ const tilmeld = enforceTilmeld(this);
309
+ const uniques = [`u:${this.groupname}`];
310
+ if (
311
+ !tilmeld.config.emailUsernames &&
312
+ tilmeld.config.userFields.includes('email')
313
+ ) {
314
+ uniques.push(`e:${this.email}`);
315
+ }
316
+ if (this.defaultPrimary) {
317
+ uniques.push(`o:default-primary`);
318
+ }
319
+ return uniques;
320
+ }
321
+
322
+ public $getAvatar(): string {
323
+ if (this.avatar != null && this.avatar !== '') {
324
+ return this.avatar;
236
325
  }
237
- if (this.$data.email == null || !this.$data.email.length) {
326
+ if (this.email == null || !this.email.length) {
238
327
  return 'https://secure.gravatar.com/avatar/?d=mm&s=40';
239
328
  }
240
329
  return (
241
330
  'https://secure.gravatar.com/avatar/' +
242
- md5(this.$data.email.trim().toLowerCase()).toString() +
331
+ crypto
332
+ .createHash('sha256')
333
+ .update(this.email.trim().toLowerCase())
334
+ .digest('hex')
335
+ .toLowerCase() +
243
336
  '?d=identicon&s=40'
244
337
  );
245
338
  }
246
339
 
247
340
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
248
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
249
- this.$referenceWake();
341
+ this.$check();
250
342
 
251
- if (
252
- input.data.abilities?.indexOf('system/admin') !== -1 &&
253
- this.$data.abilities?.indexOf('system/admin') === -1 &&
254
- tilmeld.gatekeeper('tilmeld/admin') &&
255
- !tilmeld.gatekeeper('system/admin')
343
+ if ('abilities' in input.data) {
344
+ this.$checkIncomingAbilities(input.data.abilities);
345
+ } else if (
346
+ (this.$allowlistData == null ||
347
+ this.$allowlistData.includes('abilities')) &&
348
+ (this.$privateData == null || !this.$privateData.includes('abilities'))
256
349
  ) {
257
- throw new BadDataError(
258
- "You don't have the authority to make this group a system admin."
259
- );
350
+ this.$checkIncomingAbilities(undefined);
260
351
  }
261
352
 
262
353
  super.$jsonAcceptData(input, allowConflict);
263
354
  }
264
355
 
265
356
  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
266
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
267
- this.$referenceWake();
357
+ this.$check();
358
+
359
+ if ('abilities' in patch.set) {
360
+ this.$checkIncomingAbilities(patch.set.abilities);
361
+ }
362
+
363
+ if (patch.unset.includes('abilities')) {
364
+ this.$checkIncomingAbilities(undefined);
365
+ }
366
+
367
+ super.$jsonAcceptPatch(patch, allowConflict);
368
+ }
369
+
370
+ private $checkIncomingAbilities(incoming: any) {
371
+ const tilmeld = enforceTilmeld(this);
372
+
373
+ if (
374
+ !Array.isArray(incoming) ||
375
+ incoming.find((ability) => typeof ability !== 'string')
376
+ ) {
377
+ throw new BadDataError('Abilities must be an array of strings.');
378
+ }
268
379
 
269
380
  if (
270
- patch.set.abilities?.indexOf('system/admin') !== -1 &&
271
- this.$data.abilities?.indexOf('system/admin') === -1 &&
272
- tilmeld.gatekeeper('tilmeld/admin') &&
273
- !tilmeld.gatekeeper('system/admin')
381
+ incoming.includes('system/admin') !==
382
+ this.abilities?.includes('system/admin') &&
383
+ (!tilmeld.currentUser ||
384
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
274
385
  ) {
275
386
  throw new BadDataError(
276
- "You don't have the authority to make this group a system admin."
387
+ "You don't have the authority to grant or revoke system/admin.",
277
388
  );
278
389
  }
279
390
 
280
- super.$jsonAcceptPatch(patch, allowConflict);
391
+ if (
392
+ xor(
393
+ incoming.filter((ability) => ability.startsWith('system/')),
394
+ this.abilities?.filter((ability) => ability.startsWith('system/')) ??
395
+ [],
396
+ ).length &&
397
+ (!tilmeld.currentUser ||
398
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
399
+ ) {
400
+ throw new BadDataError(
401
+ "You don't have the authority to grant or revoke system abilities.",
402
+ );
403
+ }
404
+
405
+ if (
406
+ incoming.includes('tilmeld/admin') !==
407
+ this.abilities?.includes('tilmeld/admin') &&
408
+ (!tilmeld.currentUser ||
409
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
410
+ ) {
411
+ throw new BadDataError(
412
+ "You don't have the authority to grant or revoke tilmeld/admin.",
413
+ );
414
+ }
415
+
416
+ if (
417
+ incoming.includes('tilmeld/switch') !==
418
+ this.abilities?.includes('tilmeld/switch') &&
419
+ (!tilmeld.currentUser ||
420
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
421
+ ) {
422
+ throw new BadDataError(
423
+ "You don't have the authority to grant or revoke tilmeld/switch.",
424
+ );
425
+ }
426
+
427
+ if (
428
+ xor(
429
+ incoming.filter((ability) => ability.startsWith('tilmeld/')),
430
+ this.abilities?.filter((ability) => ability.startsWith('tilmeld/')) ??
431
+ [],
432
+ ).length &&
433
+ (!tilmeld.currentUser ||
434
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
435
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
436
+ ) {
437
+ throw new BadDataError(
438
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
439
+ );
440
+ }
281
441
  }
282
442
 
283
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
284
- super.$putData(data, sdata);
443
+ public $putData(
444
+ data: GroupData,
445
+ sdata?: SerializedEntityData,
446
+ source?: 'server',
447
+ ) {
448
+ let newData = { ...data };
449
+ let newSdata = sdata && { ...sdata };
450
+ // TODO: Fix these being added by the drivers.
451
+ // @ts-ignore
452
+ delete newData.acRead;
453
+ // @ts-ignore
454
+ delete newData.acWrite;
455
+ // @ts-ignore
456
+ delete newData.acFull;
457
+ if (newSdata) {
458
+ delete newSdata.acRead;
459
+ delete newSdata.acWrite;
460
+ delete newSdata.acFull;
461
+ }
462
+ super.$putData(newData, newSdata, source);
463
+ if (source === 'server') {
464
+ this.$originalGroupname = this.groupname;
465
+ }
285
466
  this.$updateDataProtection();
286
467
  }
287
468
 
288
469
  /**
289
- * Update the data protection arrays for a user.
290
- *
291
- * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
470
+ * Update the data protection arrays for the current user.
292
471
  */
293
- public $updateDataProtection(givenUser?: User & UserData) {
294
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
295
- let user = givenUser ?? tilmeld.User.current();
472
+ public $updateDataProtection() {
473
+ const tilmeld = enforceTilmeld(this);
474
+ let [_groupname, domain] = tilmeld.config.domainSupport
475
+ ? splitn(this.groupname ?? '', '@', 2)
476
+ : [this.groupname, null];
477
+ // Lowercase the domain part.
478
+ if (domain) {
479
+ domain = domain.toLowerCase();
480
+ }
296
481
 
297
482
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
298
483
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
@@ -302,34 +487,40 @@ export default class Group extends AbleObject<GroupData> {
302
487
  }
303
488
 
304
489
  if (
305
- user != null &&
306
- ((user.abilities?.indexOf('tilmeld/admin') ?? -1) !== -1 ||
307
- (user.abilities?.indexOf('system/admin') ?? -1) !== -1)
490
+ tilmeld.gatekeeper('tilmeld/admin') ||
491
+ tilmeld.gatekeeper('system/admin')
308
492
  ) {
309
493
  // Users who can edit groups can see their data.
310
494
  this.$privateData = [];
311
495
  this.$allowlistData = undefined;
312
496
  } else if (
313
- this.$data.user != null &&
314
- user != null &&
315
- this.$data.user.$is(user)
497
+ this.user != null &&
498
+ tilmeld.currentUser != null &&
499
+ this.user.guid === tilmeld.currentUser.guid
316
500
  ) {
317
501
  // Users can see their group's data.
318
502
  this.$privateData = [];
503
+ } else if (
504
+ tilmeld.config.domainSupport &&
505
+ domain &&
506
+ tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
507
+ ) {
508
+ // Domain admins can see their groups' data.
509
+ this.$privateData = [];
319
510
  }
320
511
  }
321
512
 
322
513
  /**
323
- * Check whether the group is a descendant of a group.
514
+ * Check whether the group is a descendant of the given group.
324
515
  *
325
516
  * @param group The group, or the group's GUID.
326
517
  * @returns True or false.
327
518
  */
328
- public $isDescendant(givenGroup: (Group & GroupData) | string): boolean {
329
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
330
- let group: Group & GroupData;
519
+ public async $isDescendant(givenGroup: Group | string): Promise<boolean> {
520
+ const tilmeld = enforceTilmeld(this);
521
+ let group: Group;
331
522
  if (typeof givenGroup === 'string') {
332
- group = tilmeld.Group.factorySync(givenGroup);
523
+ group = await tilmeld.Group.factory(givenGroup);
333
524
  } else {
334
525
  group = givenGroup;
335
526
  }
@@ -337,13 +528,15 @@ export default class Group extends AbleObject<GroupData> {
337
528
  return false;
338
529
  }
339
530
  // Check to see if the group is a descendant of the given group.
340
- if (this.$data.parent == null) {
531
+ const parent = this.parent;
532
+ await parent?.$wake();
533
+ if (parent == null) {
341
534
  return false;
342
535
  }
343
- if (this.$data.parent.$is(group)) {
536
+ if (parent.$is(group)) {
344
537
  return true;
345
538
  }
346
- if (this.$data.parent.$isDescendant(group)) {
539
+ if (await parent.$isDescendant(group)) {
347
540
  return true;
348
541
  }
349
542
  return false;
@@ -354,15 +547,15 @@ export default class Group extends AbleObject<GroupData> {
354
547
  *
355
548
  * @returns An array of groups.
356
549
  */
357
- public async $getChildren() {
358
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
550
+ public async $getChildren(): Promise<Group[]> {
551
+ const tilmeld = enforceTilmeld(this);
359
552
  return await this.$nymph.getEntities(
360
553
  { class: tilmeld.Group },
361
554
  {
362
555
  type: '&',
363
556
  equal: ['enabled', true],
364
557
  ref: ['parent', this],
365
- }
558
+ },
366
559
  );
367
560
  }
368
561
 
@@ -370,20 +563,22 @@ export default class Group extends AbleObject<GroupData> {
370
563
  * Gets an array of the group's descendant groups.
371
564
  *
372
565
  * @param andSelf Include this group in the returned array.
566
+ * @param andDisabled Include disabled groups.
373
567
  * @returns An array of groups.
374
568
  */
375
569
  public async $getDescendants(
376
- andSelf = false
377
- ): Promise<(Group & GroupData)[]> {
378
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
379
- let groups: (Group & GroupData)[] = [];
570
+ andSelf = false,
571
+ andDisabled = false,
572
+ ): Promise<Group[]> {
573
+ const tilmeld = enforceTilmeld(this);
574
+ let groups: Group[] = [];
380
575
  const entities = await this.$nymph.getEntities(
381
576
  { class: tilmeld.Group },
382
577
  {
383
578
  type: '&',
384
- equal: ['enabled', true],
579
+ ...(andDisabled ? {} : { equal: ['enabled', true] }),
385
580
  ref: ['parent', this],
386
- }
581
+ },
387
582
  );
388
583
  for (let entity of entities) {
389
584
  groups = (await entity.$getDescendants(true)).concat(groups);
@@ -394,37 +589,6 @@ export default class Group extends AbleObject<GroupData> {
394
589
  return groups;
395
590
  }
396
591
 
397
- /**
398
- * Gets an array of the group's descendant groups.
399
- *
400
- * @param andSelf Include this group in the returned array.
401
- * @returns An array of groups.
402
- */
403
- public $getDescendantsSync(andSelf = false): (Group & GroupData)[] {
404
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
405
- let groups: (Group & GroupData)[] = [];
406
- let entity: EntityInterface | null;
407
- let offset = 0;
408
- do {
409
- entity = this.$nymph.driver.getEntitySync(
410
- { class: tilmeld.Group, offset },
411
- {
412
- type: '&',
413
- equal: ['enabled', true],
414
- ref: ['parent', this],
415
- }
416
- );
417
- if (entity != null) {
418
- groups = entity.$getDescendantsSync(true).concat(groups);
419
- }
420
- offset++;
421
- } while (entity != null);
422
- if (andSelf) {
423
- groups.push(this);
424
- }
425
- return groups;
426
- }
427
-
428
592
  /**
429
593
  * Get the number of parents the group has.
430
594
  *
@@ -436,19 +600,23 @@ export default class Group extends AbleObject<GroupData> {
436
600
  *
437
601
  * @returns The level of the group.
438
602
  */
439
- public $getLevel() {
440
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
441
- let group = tilmeld.Group.factorySync(this.guid ?? undefined);
603
+ public async $getLevel(): Promise<number> {
604
+ const tilmeld = enforceTilmeld(this);
605
+ let group = await tilmeld.Group.factory(this.guid ?? undefined);
606
+ let parent = group.parent;
607
+ await parent?.$wake();
442
608
  let level = 0;
443
- while (group.parent != null && group.parent.cdate != null && level < 1024) {
609
+ while (parent != null && parent.cdate != null && level < 1024) {
444
610
  level++;
445
- group = group.parent;
611
+ group = parent;
612
+ parent = group.parent;
613
+ await parent?.$wake();
446
614
  }
447
615
  return level;
448
616
  }
449
617
 
450
618
  /**
451
- * Gets an array of users in the group.
619
+ * Gets an array of enabled users in the group.
452
620
  *
453
621
  * @param descendants Include users in all descendant groups too.
454
622
  * @param limit The limit for the query.
@@ -458,10 +626,10 @@ export default class Group extends AbleObject<GroupData> {
458
626
  public async $getUsers(
459
627
  descendants = false,
460
628
  limit?: number,
461
- offset?: number
462
- ): Promise<(User & UserData)[]> {
463
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
464
- let groups: (Group & GroupData)[] = [];
629
+ offset?: number,
630
+ ): Promise<UserClass[]> {
631
+ const tilmeld = enforceTilmeld(this);
632
+ let groups: Group[] = [];
465
633
  if (descendants) {
466
634
  groups = await this.$getDescendants();
467
635
  }
@@ -471,25 +639,16 @@ export default class Group extends AbleObject<GroupData> {
471
639
  limit,
472
640
  offset,
473
641
  },
474
- {
475
- type: '&',
476
- equal: ['enabled', true],
477
- },
642
+ { type: '&', equal: ['enabled', true] },
478
643
  {
479
644
  type: '|',
480
645
  ref: [
481
646
  ['group', this],
482
647
  ['groups', this],
483
- ...groups.map((group): [string, Group & GroupData] => [
484
- 'group',
485
- group,
486
- ]),
487
- ...groups.map((group): [string, Group & GroupData] => [
488
- 'groups',
489
- group,
490
- ]),
648
+ ...groups.map((group): [string, Group] => ['group', group]),
649
+ ...groups.map((group): [string, Group] => ['groups', group]),
491
650
  ],
492
- }
651
+ },
493
652
  );
494
653
  }
495
654
 
@@ -502,15 +661,36 @@ export default class Group extends AbleObject<GroupData> {
502
661
  result: boolean;
503
662
  message: string;
504
663
  }> {
505
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
664
+ const tilmeld = enforceTilmeld(this);
506
665
  if (!tilmeld.config.emailUsernames) {
507
- if (this.$data.groupname == null || !this.$data.groupname.length) {
508
- return { result: false, message: 'Please specify a groupname.' };
666
+ let [groupname, domain] = tilmeld.config.domainSupport
667
+ ? splitn(this.groupname ?? '', '@', 2)
668
+ : [this.groupname, null];
669
+
670
+ // Lowercase the domain part.
671
+ if (domain) {
672
+ domain = domain.toLowerCase();
673
+ this.groupname = `${groupname}@${domain}`;
509
674
  }
675
+
510
676
  if (
511
- tilmeld.config.maxUsernameLength > 0 &&
512
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
677
+ this.groupname == null ||
678
+ !this.groupname.length ||
679
+ groupname == null ||
680
+ !groupname.length
513
681
  ) {
682
+ return { result: false, message: 'Please specify a groupname.' };
683
+ }
684
+ if (groupname.length < tilmeld.config.minUsernameLength) {
685
+ return {
686
+ result: false,
687
+ message:
688
+ 'Groupnames must be at least ' +
689
+ tilmeld.config.minUsernameLength +
690
+ ' characters.',
691
+ };
692
+ }
693
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
514
694
  return {
515
695
  result: false,
516
696
  message:
@@ -520,28 +700,44 @@ export default class Group extends AbleObject<GroupData> {
520
700
  };
521
701
  }
522
702
  if (
523
- difference(
524
- this.$data.groupname.split(''),
525
- tilmeld.config.validChars.split('')
526
- ).length
703
+ difference(groupname.split(''), tilmeld.config.validChars.split(''))
704
+ .length
527
705
  ) {
528
706
  return {
529
707
  result: false,
530
708
  message: tilmeld.config.validCharsNotice,
531
709
  };
532
710
  }
533
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
711
+ if (!tilmeld.config.validRegex.test(groupname)) {
534
712
  return {
535
713
  result: false,
536
714
  message: tilmeld.config.validRegexNotice,
537
715
  };
538
716
  }
717
+ if (domain != null) {
718
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
719
+ return {
720
+ result: false,
721
+ message: tilmeld.config.validDomainRegexNotice,
722
+ };
723
+ }
724
+ if (
725
+ !this.$is(tilmeld.currentUser?.group) &&
726
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
727
+ ) {
728
+ return {
729
+ result: false,
730
+ message:
731
+ "You don't have the authority to manage groups on this domain.",
732
+ };
733
+ }
734
+ }
539
735
 
540
736
  const selector: Selector = {
541
737
  type: '&',
542
738
  ilike: [
543
739
  'groupname',
544
- this.$data.groupname.replace(/([\\%_])/g, (s) => `\\${s}`),
740
+ this.groupname.replace(/([\\%_])/g, (s) => `\\${s}`),
545
741
  ],
546
742
  };
547
743
  if (this.guid != null) {
@@ -549,26 +745,44 @@ export default class Group extends AbleObject<GroupData> {
549
745
  }
550
746
  const test = await this.$nymph.getEntity(
551
747
  { class: tilmeld.Group, skipAc: true },
552
- selector
748
+ selector,
553
749
  );
554
750
  if (test != null) {
555
751
  return { result: false, message: 'That groupname is taken.' };
556
752
  }
557
753
 
754
+ for (let callback of (this.constructor as typeof Group)
755
+ .checkGroupnameCallbacks) {
756
+ if (callback) {
757
+ const result = await callback(this, {
758
+ groupname: this.groupname,
759
+ });
760
+ if (!result.result) {
761
+ return { message: 'Groupname is not available.', ...result };
762
+ }
763
+ }
764
+ }
765
+
558
766
  return {
559
767
  result: true,
560
768
  message:
561
769
  this.guid != null ? 'Groupname is valid.' : 'Groupname is available!',
562
770
  };
563
771
  } else {
564
- this.$data.email = this.$data.groupname;
565
- if (this.$data.groupname == null || !this.$data.groupname.length) {
772
+ this.email = this.groupname;
773
+ if (this.groupname == null || !this.groupname.length) {
566
774
  return { result: false, message: 'Please specify an email.' };
567
775
  }
568
- if (
569
- tilmeld.config.maxUsernameLength > 0 &&
570
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
571
- ) {
776
+ if (this.groupname.length < tilmeld.config.minUsernameLength) {
777
+ return {
778
+ result: false,
779
+ message:
780
+ 'Emails must be at least ' +
781
+ tilmeld.config.minUsernameLength +
782
+ ' characters.',
783
+ };
784
+ }
785
+ if (this.groupname.length > tilmeld.config.maxUsernameLength) {
572
786
  return {
573
787
  result: false,
574
788
  message:
@@ -588,14 +802,14 @@ export default class Group extends AbleObject<GroupData> {
588
802
  * @returns An object with a boolean 'result' entry and a 'message' entry.
589
803
  */
590
804
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
591
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
592
- if (this.$data.email === '') {
805
+ const tilmeld = enforceTilmeld(this);
806
+ if (this.email === '') {
593
807
  return { result: true, message: '' };
594
808
  }
595
- if (this.$data.email == null) {
809
+ if (this.email == null) {
596
810
  return { result: false, message: 'Please specify a valid email.' };
597
811
  }
598
- if (!tilmeld.config.validEmailRegex.test(this.$data.email)) {
812
+ if (!tilmeld.config.validEmailRegex.test(this.email)) {
599
813
  return {
600
814
  result: false,
601
815
  message: tilmeld.config.validEmailRegexNotice,
@@ -603,14 +817,14 @@ export default class Group extends AbleObject<GroupData> {
603
817
  }
604
818
  const selector: Selector = {
605
819
  type: '&',
606
- ilike: ['email', this.$data.email.replace(/([\\%_])/g, (s) => `\\${s}`)],
820
+ ilike: ['email', this.email.replace(/([\\%_])/g, (s) => `\\${s}`)],
607
821
  };
608
822
  if (this.guid != null) {
609
823
  selector['!guid'] = this.guid;
610
824
  }
611
825
  const test = await this.$nymph.getEntity(
612
826
  { class: tilmeld.Group, skipAc: true },
613
- selector
827
+ selector,
614
828
  );
615
829
  if (test != null) {
616
830
  return {
@@ -627,64 +841,130 @@ export default class Group extends AbleObject<GroupData> {
627
841
  }
628
842
 
629
843
  public async $save() {
630
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
631
- if (this.$data.groupname == null || !this.$data.groupname.trim().length) {
632
- return false;
844
+ let tilmeld = enforceTilmeld(this);
845
+ if (this.groupname == null || !this.groupname.trim().length) {
846
+ throw new BadUsernameError('Groupname must not be empty.');
847
+ }
848
+
849
+ // Lowercase the domain part.
850
+ if (tilmeld.config.domainSupport) {
851
+ const [groupname, domain] = splitn(this.groupname ?? '', '@', 2);
852
+ if (domain) {
853
+ this.groupname = `${groupname}@${domain.toLowerCase()}`;
854
+ }
855
+ }
856
+
857
+ if (tilmeld.config.domainSupport) {
858
+ const [_groupname, domain] = splitn(this.groupname ?? '', '@', 2);
859
+ const [_originalGroupname, originalDomain] = splitn(
860
+ this.$originalGroupname ?? '',
861
+ '@',
862
+ 2,
863
+ );
864
+
865
+ if (
866
+ this.guid != null &&
867
+ ((originalDomain == null && domain != null) ||
868
+ (domain == null && originalDomain != null))
869
+ ) {
870
+ throw new AccessControlError(
871
+ "Groups can't switch to or from having a domain once created.",
872
+ );
873
+ }
874
+
875
+ if (
876
+ !this.$skipAcWhenSaving &&
877
+ !tilmeld.gatekeeper('tilmeld/admin') &&
878
+ !tilmeld.gatekeeper('system/admin')
879
+ ) {
880
+ if (
881
+ domain != null &&
882
+ !this.$is(tilmeld.currentUser) &&
883
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
884
+ ) {
885
+ throw new AccessControlError(
886
+ "You don't have the authority to modify groups on this domain.",
887
+ );
888
+ }
889
+ if (
890
+ originalDomain != null &&
891
+ originalDomain !== domain &&
892
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
893
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
894
+ ) {
895
+ throw new AccessControlError(
896
+ "You don't have the authority to modify groups on this domain.",
897
+ );
898
+ }
899
+ }
633
900
  }
634
901
 
635
902
  // Formatting.
636
- this.$data.groupname = this.$data.groupname.trim();
903
+ this.groupname = this.groupname.trim();
637
904
  if (tilmeld.config.emailUsernames) {
638
- this.$data.email = this.$data.groupname;
905
+ this.email = this.groupname;
639
906
  }
640
- this.$data.email = (this.$data.email ?? '').trim();
641
- this.$data.name = (this.$data.name ?? '').trim();
642
- this.$data.phone = (this.$data.phone ?? '').trim();
643
- this.$data.enabled = !!this.$data.enabled;
907
+ this.email = (this.email ?? '').trim();
908
+ this.name = (this.name ?? '').trim();
909
+ this.phone = (this.phone ?? '').trim();
910
+ this.enabled = !!this.enabled;
644
911
 
645
912
  // Clear empty values.
646
- if (this.$data.name === '') {
647
- delete this.$data.name;
913
+ if (this.name === '' || !tilmeld.config.userFields.includes('name')) {
914
+ delete this.name;
648
915
  }
649
- if (this.$data.email === '') {
650
- delete this.$data.email;
916
+ if (this.email === '' || !tilmeld.config.userFields.includes('email')) {
917
+ delete this.email;
651
918
  }
652
- if (this.$data.avatar === '') {
653
- delete this.$data.avatar;
919
+ if (this.avatar === '') {
920
+ delete this.avatar;
654
921
  }
655
- if (this.$data.phone === '') {
656
- delete this.$data.phone;
922
+ if (this.phone === '' || !tilmeld.config.userFields.includes('phone')) {
923
+ delete this.phone;
657
924
  }
658
- if (this.$data.parent == null) {
659
- delete this.$data.parent;
925
+ if (this.parent == null) {
926
+ delete this.parent;
660
927
  }
661
- if (this.$data.user == null) {
662
- delete this.$data.user;
928
+ if (this.user == null) {
929
+ delete this.user;
663
930
  }
664
- if (!this.$data.defaultPrimary) {
665
- delete this.$data.defaultPrimary;
931
+ if (!this.defaultPrimary) {
932
+ delete this.defaultPrimary;
666
933
  }
667
- if (!this.$data.defaultSecondary) {
668
- delete this.$data.defaultSecondary;
934
+ if (!this.defaultSecondary) {
935
+ delete this.defaultSecondary;
669
936
  }
670
- if (!this.$data.unverifiedSecondary) {
671
- delete this.$data.unverifiedSecondary;
937
+ if (!this.unverifiedSecondary) {
938
+ delete this.unverifiedSecondary;
672
939
  }
673
940
 
674
- // Groups should never have 'system/admin' or 'tilmeld/admin' abilities.
675
- this.$data.abilities = difference(this.$data.abilities ?? [], [
676
- 'system/admin',
677
- 'tilmeld/admin',
678
- null,
679
- undefined,
680
- ]) as string[];
941
+ // Groups should never have 'system/admin', 'tilmeld/admin',
942
+ // 'tilmeld/switch', or system abilities.
943
+ this.abilities = (this.abilities ?? []).filter((ability) => {
944
+ if (
945
+ ['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
946
+ -1
947
+ ) {
948
+ return false;
949
+ }
950
+ if (ability == null) {
951
+ return false;
952
+ }
953
+ if (ability.startsWith('system/')) {
954
+ return false;
955
+ }
956
+ return true;
957
+ }) as string[];
681
958
 
682
959
  // Verification.
683
960
  const unCheck = await this.$checkGroupname();
684
961
  if (!unCheck.result) {
685
962
  throw new BadUsernameError(unCheck.message);
686
963
  }
687
- if (!tilmeld.config.emailUsernames) {
964
+ if (
965
+ !tilmeld.config.emailUsernames &&
966
+ tilmeld.config.userFields.includes('email')
967
+ ) {
688
968
  const emCheck = await this.$checkEmail();
689
969
  if (!emCheck.result) {
690
970
  throw new BadEmailError(emCheck.message);
@@ -692,41 +972,85 @@ export default class Group extends AbleObject<GroupData> {
692
972
  }
693
973
 
694
974
  // Validate group parent. Make sure it's not a descendant of this group.
975
+ const parent = this.parent;
976
+ await parent?.$wake();
695
977
  if (
696
- this.$data.parent &&
697
- (this.$data.parent.cdate == null ||
698
- this.$is(this.$data.parent) ||
699
- this.$data.parent.$isDescendant(this))
978
+ parent &&
979
+ (parent.cdate == null ||
980
+ this.$is(parent) ||
981
+ (await parent.$isDescendant(this)))
700
982
  ) {
701
983
  throw new BadDataError(
702
- "Group parent can't be itself or descendant of itself."
984
+ "Group parent can't be itself or descendant of itself.",
703
985
  );
704
986
  }
705
987
 
706
988
  try {
707
- tilmeld.config.validatorGroup(this);
989
+ tilmeld.config.validatorGroup(tilmeld, this);
708
990
  } catch (e: any) {
709
991
  throw new BadDataError(e?.message);
710
992
  }
711
993
 
712
994
  // Only one default primary group is allowed.
713
- if (this.$data.defaultPrimary) {
995
+ if (this.defaultPrimary) {
714
996
  const currentPrimary = await this.$nymph.getEntity(
715
997
  { class: tilmeld.Group },
716
- { type: '&', truthy: 'defaultPrimary' }
998
+ { type: '&', truthy: 'defaultPrimary' },
717
999
  );
718
1000
  if (currentPrimary != null && !this.$is(currentPrimary)) {
719
1001
  currentPrimary.defaultPrimary = false;
720
- if (!(await currentPrimary.$save())) {
1002
+ try {
1003
+ if (this.$skipAcWhenSaving) {
1004
+ await currentPrimary.$saveSkipAC();
1005
+ } else {
1006
+ await currentPrimary.$save();
1007
+ }
1008
+ } catch (e: any) {
721
1009
  throw new CouldNotChangeDefaultPrimaryGroupError(
722
1010
  'Could not change new user primary group from ' +
723
- `${currentPrimary.groupname}.`
1011
+ `${currentPrimary.groupname}.`,
724
1012
  );
725
1013
  }
726
1014
  }
727
1015
  }
728
1016
 
729
- return await super.$save();
1017
+ let preGuid = this.guid;
1018
+ let preCdate = this.cdate;
1019
+ let preMdate = this.mdate;
1020
+ return await transaction(
1021
+ this.$nymph,
1022
+ 'tilmeld-save-group-' + (this.guid || nanoid()),
1023
+ async (nymph) => {
1024
+ this.$setNymph(nymph);
1025
+
1026
+ for (let callback of (this.constructor as typeof Group)
1027
+ .beforeSaveCallbacks) {
1028
+ if (callback) {
1029
+ await callback(this);
1030
+ }
1031
+ }
1032
+
1033
+ await super.$save();
1034
+
1035
+ for (let callback of (this.constructor as typeof Group)
1036
+ .afterSaveCallbacks) {
1037
+ if (callback) {
1038
+ await callback(this);
1039
+ }
1040
+ }
1041
+ },
1042
+ async (nymph, committed) => {
1043
+ this.$setNymph(nymph);
1044
+
1045
+ if (committed) {
1046
+ this.$originalGroupname = this.groupname;
1047
+ } else {
1048
+ this.guid = preGuid;
1049
+ this.cdate = preCdate;
1050
+ this.mdate = preMdate;
1051
+ }
1052
+ },
1053
+ );
730
1054
  }
731
1055
 
732
1056
  /*
@@ -734,7 +1058,7 @@ export default class Group extends AbleObject<GroupData> {
734
1058
  */
735
1059
  public async $saveSkipAC() {
736
1060
  this.$skipAcWhenSaving = true;
737
- return await this.$save();
1061
+ await this.$save();
738
1062
  }
739
1063
 
740
1064
  public $tilmeldSaveSkipAC() {
@@ -745,77 +1069,166 @@ export default class Group extends AbleObject<GroupData> {
745
1069
  return false;
746
1070
  }
747
1071
 
1072
+ @transactional
748
1073
  public async $delete() {
749
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
750
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
751
- throw new BadDataError("You don't have the authority to delete groups.");
752
- }
753
-
754
- const transaction = 'tilmeld-delete-' + this.guid;
755
- const nymph = this.$nymph;
756
- const tnymph = await nymph.startTransaction(transaction);
757
- this.$nymph = tnymph;
758
- tilmeld = this.$nymph.tilmeld as Tilmeld;
759
-
760
- // Delete descendants.
761
- const descendants = await this.$getDescendants();
762
- if (descendants.length) {
763
- for (let curGroup of descendants) {
764
- if (!(await curGroup.$delete())) {
765
- await tnymph.rollback(transaction);
766
- this.$nymph = nymph;
767
- return false;
1074
+ let tilmeld = enforceTilmeld(this);
1075
+ const User = this.$nymph.getEntityClass(UserClass);
1076
+
1077
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
1078
+ throw new AccessControlError(
1079
+ "You don't have the authority to delete groups.",
1080
+ );
1081
+ }
1082
+ if (this.groupname !== this.$originalGroupname) {
1083
+ throw new AccessControlError(
1084
+ "You can't delete a group while switching its groupname.",
1085
+ );
1086
+ }
1087
+
1088
+ for (let callback of (this.constructor as typeof Group)
1089
+ .beforeDeleteCallbacks) {
1090
+ if (callback) {
1091
+ await callback(this);
1092
+ }
1093
+ }
1094
+
1095
+ // Delete children.
1096
+ const children = await this.$nymph.getEntities(
1097
+ { class: tilmeld.Group, skipAc: true },
1098
+ { type: '&', ref: ['parent', this] },
1099
+ );
1100
+ if (children.length) {
1101
+ for (let curGroup of children) {
1102
+ if (this.$skipAcWhenDeleting) {
1103
+ await curGroup.$deleteSkipAC();
1104
+ } else {
1105
+ await curGroup.$delete();
768
1106
  }
769
1107
  }
770
1108
  }
771
1109
 
772
1110
  // Remove users from this primary group.
773
- const primaryUsers = await tnymph.getEntities(
774
- {
775
- class: tilmeld.User,
776
- skipAc: true,
777
- },
778
- {
779
- type: '&',
780
- ref: ['group', this],
781
- }
1111
+ const primaryUsers = await this.$nymph.getEntities(
1112
+ { class: User, skipAc: true },
1113
+ { type: '&', ref: ['group', this] },
782
1114
  );
783
1115
  for (let user of primaryUsers) {
784
1116
  delete user.group;
785
- if (!(await user.$save())) {
786
- await tnymph.rollback(transaction);
787
- this.$nymph = nymph;
788
- return false;
1117
+ if (this.$skipAcWhenDeleting) {
1118
+ await user.$saveSkipAC();
1119
+ } else {
1120
+ await user.$save();
789
1121
  }
790
1122
  }
791
1123
 
792
1124
  // Remove users from this secondary group.
793
- const secondaryUsers = await tnymph.getEntities(
794
- {
795
- class: tilmeld.User,
796
- skipAc: true,
797
- },
798
- {
799
- type: '&',
800
- ref: ['groups', this],
801
- }
1125
+ const secondaryUsers = await this.$nymph.getEntities(
1126
+ { class: User, skipAc: true },
1127
+ { type: '&', ref: ['groups', this] },
802
1128
  );
803
1129
  for (let user of secondaryUsers) {
804
1130
  user.$delGroup(this);
805
- if (!(await user.$save())) {
806
- await tnymph.rollback(transaction);
807
- return false;
1131
+ if (this.$skipAcWhenDeleting) {
1132
+ await user.$saveSkipAC();
1133
+ } else {
1134
+ await user.$save();
808
1135
  }
809
1136
  }
810
1137
 
811
1138
  // Delete the group.
812
- const success = await super.$delete();
813
- if (success) {
814
- await tnymph.commit(transaction);
815
- } else {
816
- await tnymph.rollback(transaction);
1139
+ await super.$delete();
1140
+
1141
+ for (let callback of (this.constructor as typeof Group)
1142
+ .afterDeleteCallbacks) {
1143
+ if (callback) {
1144
+ await callback(this);
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ /*
1150
+ * This should *never* be accessible on the client.
1151
+ */
1152
+ async $deleteSkipAC() {
1153
+ this.$skipAcWhenDeleting = true;
1154
+ await this.$delete();
1155
+ }
1156
+
1157
+ $tilmeldDeleteSkipAC() {
1158
+ if (this.$skipAcWhenDeleting) {
1159
+ this.$skipAcWhenDeleting = false;
1160
+ return true;
1161
+ }
1162
+ return false;
1163
+ }
1164
+
1165
+ public static on<T extends GroupEventType>(
1166
+ event: T,
1167
+ callback: T extends 'checkGroupname'
1168
+ ? TilmeldCheckGroupnameCallback
1169
+ : T extends 'beforeSave'
1170
+ ? TilmeldBeforeGroupSaveCallback
1171
+ : T extends 'afterSave'
1172
+ ? TilmeldAfterGroupSaveCallback
1173
+ : T extends 'beforeDelete'
1174
+ ? TilmeldBeforeGroupDeleteCallback
1175
+ : T extends 'afterDelete'
1176
+ ? TilmeldAfterGroupDeleteCallback
1177
+ : never,
1178
+ ) {
1179
+ const prop = (event + 'Callbacks') as T extends 'checkGroupname'
1180
+ ? 'checkGroupnameCallbacks'
1181
+ : T extends 'beforeSave'
1182
+ ? 'beforeSaveCallbacks'
1183
+ : T extends 'afterSave'
1184
+ ? 'afterSaveCallbacks'
1185
+ : T extends 'beforeDelete'
1186
+ ? 'beforeDeleteCallbacks'
1187
+ : T extends 'afterDelete'
1188
+ ? 'afterDeleteCallbacks'
1189
+ : never;
1190
+ if (!(prop in this)) {
1191
+ throw new Error('Invalid event type.');
1192
+ }
1193
+ // @ts-ignore: The callback should always be the right type here.
1194
+ this[prop].push(callback);
1195
+ return () => this.off(event, callback);
1196
+ }
1197
+
1198
+ public static off<T extends GroupEventType>(
1199
+ event: T,
1200
+ callback: T extends 'checkGroupname'
1201
+ ? TilmeldCheckGroupnameCallback
1202
+ : T extends 'beforeSave'
1203
+ ? TilmeldBeforeGroupSaveCallback
1204
+ : T extends 'afterSave'
1205
+ ? TilmeldAfterGroupSaveCallback
1206
+ : T extends 'beforeDelete'
1207
+ ? TilmeldBeforeGroupDeleteCallback
1208
+ : T extends 'afterDelete'
1209
+ ? TilmeldAfterGroupDeleteCallback
1210
+ : never,
1211
+ ) {
1212
+ const prop = (event + 'Callbacks') as T extends 'checkGroupname'
1213
+ ? 'checkGroupnameCallbacks'
1214
+ : T extends 'beforeSave'
1215
+ ? 'beforeSaveCallbacks'
1216
+ : T extends 'afterSave'
1217
+ ? 'afterSaveCallbacks'
1218
+ : T extends 'beforeDelete'
1219
+ ? 'beforeDeleteCallbacks'
1220
+ : T extends 'afterDelete'
1221
+ ? 'afterDeleteCallbacks'
1222
+ : never;
1223
+ if (!(prop in this)) {
1224
+ return false;
1225
+ }
1226
+ // @ts-ignore: The callback should always be the right type here.
1227
+ const i = this[prop].indexOf(callback);
1228
+ if (i > -1) {
1229
+ // @ts-ignore: The callback should always be the right type here.
1230
+ this[prop].splice(i, 1);
817
1231
  }
818
- this.$nymph = nymph;
819
- return success;
1232
+ return true;
820
1233
  }
821
1234
  }