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

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 +408 -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 +140 -15
  7. package/dist/Group.js +466 -191
  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 +239 -12
  12. package/dist/Tilmeld.js +634 -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 +365 -22
  17. package/dist/User.js +1265 -364
  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 +478 -226
  61. package/src/Tilmeld.ts +418 -254
  62. package/src/Tilmeld.types.ts +2 -2
  63. package/src/User.test.ts +163 -6
  64. package/src/User.ts +1428 -444
  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/src/Group.ts CHANGED
@@ -1,24 +1,27 @@
1
- import {
1
+ import crypto from 'node:crypto';
2
+ import type {
3
+ Nymph,
2
4
  EntityData,
3
- EntityInterface,
4
5
  EntityJson,
5
6
  EntityPatch,
6
7
  Options,
7
8
  Selector,
8
9
  SerializedEntityData,
9
10
  } from '@nymphjs/nymph';
10
- import md5 from 'crypto-js/md5';
11
- import { difference } from 'lodash';
11
+ import { difference, xor } from 'lodash-es';
12
+ import { splitn } from '@sciactive/splitn';
12
13
 
13
- import type Tilmeld from './Tilmeld';
14
- import AbleObject from './AbleObject';
14
+ import { enforceTilmeld } from './enforceTilmeld.js';
15
+ import AbleObject from './AbleObject.js';
15
16
  import {
17
+ AccessControlError,
16
18
  BadDataError,
17
19
  BadEmailError,
18
20
  BadUsernameError,
19
21
  CouldNotChangeDefaultPrimaryGroupError,
20
- } from './errors';
21
- import User, { UserData } from './User';
22
+ } from './errors/index.js';
23
+ import UserClass from './User.js';
24
+ import type { UserData } from './User.js';
22
25
 
23
26
  export type GroupData = {
24
27
  /**
@@ -52,7 +55,7 @@ export type GroupData = {
52
55
  /**
53
56
  * If generatePrimary is on, this will be the user who generated this group.
54
57
  */
55
- user?: (User & UserData) | null;
58
+ user?: (UserClass & UserData) | null;
56
59
 
57
60
  /**
58
61
  * Whether the group can be used.
@@ -85,7 +88,6 @@ export default class Group extends AbleObject<GroupData> {
85
88
  /**
86
89
  * The instance of Tilmeld to use for queries.
87
90
  */
88
- static tilmeld: Tilmeld;
89
91
  static ETYPE = 'tilmeld_group';
90
92
  static class = 'Group';
91
93
 
@@ -112,16 +114,20 @@ export default class Group extends AbleObject<GroupData> {
112
114
  protected $allowlistTags?: string[] = [];
113
115
 
114
116
  /**
115
- * This is explicitly used only during the registration proccess.
117
+ * This should only be used by the backend.
116
118
  */
117
119
  private $skipAcWhenSaving = false;
118
-
119
- static async factory(guid?: string): Promise<Group & GroupData> {
120
- return (await super.factory(guid)) as Group & GroupData;
121
- }
120
+ /**
121
+ * This should only be used by the backend.
122
+ */
123
+ private $skipAcWhenDeleting = false;
124
+ /**
125
+ * Used to save the current groupname for domain admin permissions.
126
+ */
127
+ public $originalGroupname?: string;
122
128
 
123
129
  static async factoryGroupname(
124
- groupname?: string
130
+ groupname?: string,
125
131
  ): Promise<Group & GroupData> {
126
132
  const entity = new this();
127
133
  if (groupname != null) {
@@ -132,7 +138,7 @@ export default class Group extends AbleObject<GroupData> {
132
138
  {
133
139
  type: '&',
134
140
  ilike: ['groupname', groupname.replace(/([\\%_])/g, (s) => `\\${s}`)],
135
- }
141
+ },
136
142
  );
137
143
  if (entity != null) {
138
144
  return entity;
@@ -141,21 +147,6 @@ export default class Group extends AbleObject<GroupData> {
141
147
  return entity;
142
148
  }
143
149
 
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
150
  /**
160
151
  * Get all the groups that can be assigned as primary groups.
161
152
  *
@@ -165,16 +156,17 @@ export default class Group extends AbleObject<GroupData> {
165
156
  */
166
157
  public static async getPrimaryGroups(
167
158
  options?: Options,
168
- selectors?: Selector[]
159
+ selectors?: Selector[],
169
160
  ) {
170
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
161
+ const tilmeld = enforceTilmeld(this);
162
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
171
163
  throw new Error("You don't have permission to do that.");
172
164
  }
173
165
 
174
166
  return await this.getAssignableGroups(
175
- this.tilmeld.config.highestPrimary,
176
- { ...options, class: this.tilmeld.Group, return: 'entity' },
177
- [...(selectors ?? [])]
167
+ tilmeld.config.highestPrimary,
168
+ { ...options, class: tilmeld.Group, return: 'entity' },
169
+ [...(selectors ?? [])],
178
170
  );
179
171
  }
180
172
 
@@ -187,24 +179,26 @@ export default class Group extends AbleObject<GroupData> {
187
179
  */
188
180
  public static async getSecondaryGroups(
189
181
  options?: Options,
190
- selectors?: Selector[]
182
+ selectors?: Selector[],
191
183
  ) {
192
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
184
+ const tilmeld = enforceTilmeld(this);
185
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
193
186
  throw new Error("You don't have permission to do that.");
194
187
  }
195
188
 
196
189
  return await this.getAssignableGroups(
197
- this.tilmeld.config.highestSecondary,
198
- { ...options, class: this.tilmeld.Group, return: 'entity' },
199
- [...(selectors ?? [])]
190
+ tilmeld.config.highestSecondary,
191
+ { ...options, class: tilmeld.Group, return: 'entity' },
192
+ [...(selectors ?? [])],
200
193
  );
201
194
  }
202
195
 
203
196
  private static async getAssignableGroups(
204
197
  highestParent: string | boolean,
205
198
  options: Options<typeof Group>,
206
- selectors: Selector[]
199
+ selectors: Selector[],
207
200
  ) {
201
+ const tilmeld = enforceTilmeld(this);
208
202
  let assignableGroups: (Group & GroupData)[] = [];
209
203
 
210
204
  if (highestParent === false) {
@@ -212,24 +206,68 @@ export default class Group extends AbleObject<GroupData> {
212
206
  }
213
207
 
214
208
  assignableGroups = await this.nymph.getEntities(
215
- { ...options, class: this.tilmeld.Group },
216
- ...selectors
209
+ { ...options, class: tilmeld.Group },
210
+ ...selectors,
217
211
  );
218
212
  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
- });
213
+ assignableGroups = (
214
+ await Promise.all(
215
+ assignableGroups.map(async (group) => {
216
+ let curGroup = group;
217
+ let parent = curGroup.parent;
218
+ await parent?.$wake();
219
+ while (parent != null && parent?.cdate != null) {
220
+ if (parent?.guid === highestParent) {
221
+ return group;
222
+ }
223
+ curGroup = parent;
224
+ parent = curGroup.parent;
225
+ await parent?.$wake();
226
+ }
227
+ return null;
228
+ }),
229
+ )
230
+ ).filter((group) => group != null) as (Group & GroupData)[];
229
231
  }
230
232
  return assignableGroups;
231
233
  }
232
234
 
235
+ constructor() {
236
+ super();
237
+
238
+ // Defaults.
239
+ this.$data.enabled = true;
240
+ this.$data.abilities = [];
241
+ this.$updateDataProtection();
242
+ }
243
+
244
+ $setNymph(nymph: Nymph) {
245
+ this.$nymph = nymph;
246
+ if (!this.$asleep()) {
247
+ if (this.$data.user && this.$data.user.$nymph !== nymph) {
248
+ this.$data.user.$setNymph(nymph);
249
+ }
250
+ if (this.$data.parent && this.$data.parent.$nymph !== nymph) {
251
+ this.$data.parent.$setNymph(nymph);
252
+ }
253
+ }
254
+ }
255
+
256
+ async $getUniques(): Promise<string[]> {
257
+ const tilmeld = enforceTilmeld(this);
258
+ const uniques = [`u:${this.$data.groupname}`];
259
+ if (
260
+ !tilmeld.config.emailUsernames &&
261
+ tilmeld.config.userFields.includes('email')
262
+ ) {
263
+ uniques.push(`e:${this.$data.email}`);
264
+ }
265
+ if (this.$data.defaultPrimary) {
266
+ uniques.push(`o:default-primary`);
267
+ }
268
+ return uniques;
269
+ }
270
+
233
271
  public $getAvatar() {
234
272
  if (this.$data.avatar != null && this.$data.avatar !== '') {
235
273
  return this.$data.avatar;
@@ -239,49 +277,104 @@ export default class Group extends AbleObject<GroupData> {
239
277
  }
240
278
  return (
241
279
  'https://secure.gravatar.com/avatar/' +
242
- md5(this.$data.email.trim().toLowerCase()).toString() +
280
+ crypto
281
+ .createHash('sha256')
282
+ .update(this.$data.email.trim().toLowerCase())
283
+ .digest('hex')
284
+ .toLowerCase() +
243
285
  '?d=identicon&s=40'
244
286
  );
245
287
  }
246
288
 
247
289
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
248
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
249
- this.$referenceWake();
290
+ this.$check();
250
291
 
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')
256
- ) {
257
- throw new BadDataError(
258
- "You don't have the authority to make this group a system admin."
259
- );
292
+ if ('abilities' in input.data) {
293
+ this.$checkIncomingAbilities(input.data.abilities);
260
294
  }
261
295
 
262
296
  super.$jsonAcceptData(input, allowConflict);
263
297
  }
264
298
 
265
299
  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
266
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
267
- this.$referenceWake();
300
+ this.$check();
301
+
302
+ if ('abilities' in patch.set) {
303
+ this.$checkIncomingAbilities(patch.set.abilities);
304
+ }
305
+
306
+ super.$jsonAcceptPatch(patch, allowConflict);
307
+ }
308
+
309
+ private $checkIncomingAbilities(incoming: any) {
310
+ const tilmeld = enforceTilmeld(this);
268
311
 
269
312
  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')
313
+ !Array.isArray(incoming) ||
314
+ incoming.find((ability) => typeof ability !== 'string')
315
+ ) {
316
+ throw new BadDataError('Abilities must be an array of strings.');
317
+ }
318
+
319
+ if (
320
+ incoming.includes('system/admin') !==
321
+ this.$data.abilities?.includes('system/admin') &&
322
+ (!tilmeld.currentUser ||
323
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
274
324
  ) {
275
325
  throw new BadDataError(
276
- "You don't have the authority to make this group a system admin."
326
+ "You don't have the authority to grant or revoke system/admin.",
277
327
  );
278
328
  }
279
329
 
280
- super.$jsonAcceptPatch(patch, allowConflict);
330
+ if (
331
+ incoming.includes('tilmeld/admin') !==
332
+ this.$data.abilities?.includes('tilmeld/admin') &&
333
+ (!tilmeld.currentUser ||
334
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
335
+ ) {
336
+ throw new BadDataError(
337
+ "You don't have the authority to grant or revoke tilmeld/admin.",
338
+ );
339
+ }
340
+
341
+ if (
342
+ incoming.includes('tilmeld/switch') !==
343
+ this.$data.abilities?.includes('tilmeld/switch') &&
344
+ (!tilmeld.currentUser ||
345
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
346
+ ) {
347
+ throw new BadDataError(
348
+ "You don't have the authority to grant or revoke tilmeld/switch.",
349
+ );
350
+ }
351
+
352
+ if (
353
+ xor(
354
+ incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
355
+ this.$data.abilities?.filter((ability: string) =>
356
+ ability.startsWith('tilmeld/'),
357
+ ) ?? [],
358
+ ).length &&
359
+ (!tilmeld.currentUser ||
360
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
361
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
362
+ ) {
363
+ throw new BadDataError(
364
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
365
+ );
366
+ }
281
367
  }
282
368
 
283
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
284
- super.$putData(data, sdata);
369
+ public $putData(
370
+ data: EntityData,
371
+ sdata?: SerializedEntityData,
372
+ source?: 'server',
373
+ ) {
374
+ super.$putData(data, sdata, source);
375
+ if (source === 'server') {
376
+ this.$originalGroupname = this.$data.groupname;
377
+ }
285
378
  this.$updateDataProtection();
286
379
  }
287
380
 
@@ -290,9 +383,16 @@ export default class Group extends AbleObject<GroupData> {
290
383
  *
291
384
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
292
385
  */
293
- public $updateDataProtection(givenUser?: User & UserData) {
294
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
386
+ public $updateDataProtection(givenUser?: UserClass & UserData) {
387
+ const tilmeld = enforceTilmeld(this);
295
388
  let user = givenUser ?? tilmeld.User.current();
389
+ let [_groupname, domain] = tilmeld.config.domainSupport
390
+ ? splitn(this.$data.groupname ?? '', '@', 2)
391
+ : [this.$data.groupname, null];
392
+ // Lowercase the domain part.
393
+ if (domain) {
394
+ domain = domain.toLowerCase();
395
+ }
296
396
 
297
397
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
298
398
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
@@ -303,8 +403,8 @@ export default class Group extends AbleObject<GroupData> {
303
403
 
304
404
  if (
305
405
  user != null &&
306
- ((user.abilities?.indexOf('tilmeld/admin') ?? -1) !== -1 ||
307
- (user.abilities?.indexOf('system/admin') ?? -1) !== -1)
406
+ (user.abilities?.includes('tilmeld/admin') ||
407
+ user.abilities?.includes('system/admin'))
308
408
  ) {
309
409
  // Users who can edit groups can see their data.
310
410
  this.$privateData = [];
@@ -312,10 +412,18 @@ export default class Group extends AbleObject<GroupData> {
312
412
  } else if (
313
413
  this.$data.user != null &&
314
414
  user != null &&
315
- this.$data.user.$is(user)
415
+ this.$data.user.guid === user.guid
316
416
  ) {
317
417
  // Users can see their group's data.
318
418
  this.$privateData = [];
419
+ } else if (
420
+ user != null &&
421
+ tilmeld.config.domainSupport &&
422
+ domain &&
423
+ user.abilities?.includes(`tilmeld/domain/${domain}/admin`)
424
+ ) {
425
+ // Domain admins can see their groups' data.
426
+ this.$privateData = [];
319
427
  }
320
428
  }
321
429
 
@@ -325,11 +433,13 @@ export default class Group extends AbleObject<GroupData> {
325
433
  * @param group The group, or the group's GUID.
326
434
  * @returns True or false.
327
435
  */
328
- public $isDescendant(givenGroup: (Group & GroupData) | string): boolean {
329
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
436
+ public async $isDescendant(
437
+ givenGroup: (Group & GroupData) | string,
438
+ ): Promise<boolean> {
439
+ const tilmeld = enforceTilmeld(this);
330
440
  let group: Group & GroupData;
331
441
  if (typeof givenGroup === 'string') {
332
- group = tilmeld.Group.factorySync(givenGroup);
442
+ group = await tilmeld.Group.factory(givenGroup);
333
443
  } else {
334
444
  group = givenGroup;
335
445
  }
@@ -337,13 +447,15 @@ export default class Group extends AbleObject<GroupData> {
337
447
  return false;
338
448
  }
339
449
  // Check to see if the group is a descendant of the given group.
340
- if (this.$data.parent == null) {
450
+ const parent = this.$data.parent;
451
+ await parent?.$wake();
452
+ if (parent == null) {
341
453
  return false;
342
454
  }
343
- if (this.$data.parent.$is(group)) {
455
+ if (parent.$is(group)) {
344
456
  return true;
345
457
  }
346
- if (this.$data.parent.$isDescendant(group)) {
458
+ if (await parent.$isDescendant(group)) {
347
459
  return true;
348
460
  }
349
461
  return false;
@@ -355,14 +467,14 @@ export default class Group extends AbleObject<GroupData> {
355
467
  * @returns An array of groups.
356
468
  */
357
469
  public async $getChildren() {
358
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
470
+ const tilmeld = enforceTilmeld(this);
359
471
  return await this.$nymph.getEntities(
360
472
  { class: tilmeld.Group },
361
473
  {
362
474
  type: '&',
363
475
  equal: ['enabled', true],
364
476
  ref: ['parent', this],
365
- }
477
+ },
366
478
  );
367
479
  }
368
480
 
@@ -373,9 +485,9 @@ export default class Group extends AbleObject<GroupData> {
373
485
  * @returns An array of groups.
374
486
  */
375
487
  public async $getDescendants(
376
- andSelf = false
488
+ andSelf = false,
377
489
  ): Promise<(Group & GroupData)[]> {
378
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
490
+ const tilmeld = enforceTilmeld(this);
379
491
  let groups: (Group & GroupData)[] = [];
380
492
  const entities = await this.$nymph.getEntities(
381
493
  { class: tilmeld.Group },
@@ -383,7 +495,7 @@ export default class Group extends AbleObject<GroupData> {
383
495
  type: '&',
384
496
  equal: ['enabled', true],
385
497
  ref: ['parent', this],
386
- }
498
+ },
387
499
  );
388
500
  for (let entity of entities) {
389
501
  groups = (await entity.$getDescendants(true)).concat(groups);
@@ -394,37 +506,6 @@ export default class Group extends AbleObject<GroupData> {
394
506
  return groups;
395
507
  }
396
508
 
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
509
  /**
429
510
  * Get the number of parents the group has.
430
511
  *
@@ -436,13 +517,17 @@ export default class Group extends AbleObject<GroupData> {
436
517
  *
437
518
  * @returns The level of the group.
438
519
  */
439
- public $getLevel() {
440
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
441
- let group = tilmeld.Group.factorySync(this.guid ?? undefined);
520
+ public async $getLevel() {
521
+ const tilmeld = enforceTilmeld(this);
522
+ let group = await tilmeld.Group.factory(this.guid ?? undefined);
523
+ let parent = group.parent;
524
+ await parent?.$wake();
442
525
  let level = 0;
443
- while (group.parent != null && group.parent.cdate != null && level < 1024) {
526
+ while (parent != null && parent.cdate != null && level < 1024) {
444
527
  level++;
445
- group = group.parent;
528
+ group = parent;
529
+ parent = group.parent;
530
+ await parent?.$wake();
446
531
  }
447
532
  return level;
448
533
  }
@@ -458,9 +543,9 @@ export default class Group extends AbleObject<GroupData> {
458
543
  public async $getUsers(
459
544
  descendants = false,
460
545
  limit?: number,
461
- offset?: number
462
- ): Promise<(User & UserData)[]> {
463
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
546
+ offset?: number,
547
+ ): Promise<(UserClass & UserData)[]> {
548
+ const tilmeld = enforceTilmeld(this);
464
549
  let groups: (Group & GroupData)[] = [];
465
550
  if (descendants) {
466
551
  groups = await this.$getDescendants();
@@ -489,7 +574,7 @@ export default class Group extends AbleObject<GroupData> {
489
574
  group,
490
575
  ]),
491
576
  ],
492
- }
577
+ },
493
578
  );
494
579
  }
495
580
 
@@ -502,15 +587,36 @@ export default class Group extends AbleObject<GroupData> {
502
587
  result: boolean;
503
588
  message: string;
504
589
  }> {
505
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
590
+ const tilmeld = enforceTilmeld(this);
506
591
  if (!tilmeld.config.emailUsernames) {
507
- if (this.$data.groupname == null || !this.$data.groupname.length) {
508
- return { result: false, message: 'Please specify a groupname.' };
592
+ let [groupname, domain] = tilmeld.config.domainSupport
593
+ ? splitn(this.$data.groupname ?? '', '@', 2)
594
+ : [this.$data.groupname, null];
595
+
596
+ // Lowercase the domain part.
597
+ if (domain) {
598
+ domain = domain.toLowerCase();
599
+ this.$data.groupname = `${groupname}@${domain}`;
509
600
  }
601
+
510
602
  if (
511
- tilmeld.config.maxUsernameLength > 0 &&
512
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
603
+ this.$data.groupname == null ||
604
+ !this.$data.groupname.length ||
605
+ groupname == null ||
606
+ !groupname.length
513
607
  ) {
608
+ return { result: false, message: 'Please specify a groupname.' };
609
+ }
610
+ if (groupname.length < tilmeld.config.minUsernameLength) {
611
+ return {
612
+ result: false,
613
+ message:
614
+ 'Groupnames must be at least ' +
615
+ tilmeld.config.minUsernameLength +
616
+ ' characters.',
617
+ };
618
+ }
619
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
514
620
  return {
515
621
  result: false,
516
622
  message:
@@ -520,22 +626,38 @@ export default class Group extends AbleObject<GroupData> {
520
626
  };
521
627
  }
522
628
  if (
523
- difference(
524
- this.$data.groupname.split(''),
525
- tilmeld.config.validChars.split('')
526
- ).length
629
+ difference(groupname.split(''), tilmeld.config.validChars.split(''))
630
+ .length
527
631
  ) {
528
632
  return {
529
633
  result: false,
530
634
  message: tilmeld.config.validCharsNotice,
531
635
  };
532
636
  }
533
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
637
+ if (!tilmeld.config.validRegex.test(groupname)) {
534
638
  return {
535
639
  result: false,
536
640
  message: tilmeld.config.validRegexNotice,
537
641
  };
538
642
  }
643
+ if (domain != null) {
644
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
645
+ return {
646
+ result: false,
647
+ message: tilmeld.config.validDomainRegexNotice,
648
+ };
649
+ }
650
+ if (
651
+ !this.$is(tilmeld.currentUser?.group) &&
652
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
653
+ ) {
654
+ return {
655
+ result: false,
656
+ message:
657
+ "You don't have the authority to manage groups on this domain.",
658
+ };
659
+ }
660
+ }
539
661
 
540
662
  const selector: Selector = {
541
663
  type: '&',
@@ -549,7 +671,7 @@ export default class Group extends AbleObject<GroupData> {
549
671
  }
550
672
  const test = await this.$nymph.getEntity(
551
673
  { class: tilmeld.Group, skipAc: true },
552
- selector
674
+ selector,
553
675
  );
554
676
  if (test != null) {
555
677
  return { result: false, message: 'That groupname is taken.' };
@@ -565,10 +687,16 @@ export default class Group extends AbleObject<GroupData> {
565
687
  if (this.$data.groupname == null || !this.$data.groupname.length) {
566
688
  return { result: false, message: 'Please specify an email.' };
567
689
  }
568
- if (
569
- tilmeld.config.maxUsernameLength > 0 &&
570
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
571
- ) {
690
+ if (this.$data.groupname.length < tilmeld.config.minUsernameLength) {
691
+ return {
692
+ result: false,
693
+ message:
694
+ 'Emails must be at least ' +
695
+ tilmeld.config.minUsernameLength +
696
+ ' characters.',
697
+ };
698
+ }
699
+ if (this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
572
700
  return {
573
701
  result: false,
574
702
  message:
@@ -588,7 +716,7 @@ export default class Group extends AbleObject<GroupData> {
588
716
  * @returns An object with a boolean 'result' entry and a 'message' entry.
589
717
  */
590
718
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
591
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
719
+ const tilmeld = enforceTilmeld(this);
592
720
  if (this.$data.email === '') {
593
721
  return { result: true, message: '' };
594
722
  }
@@ -610,7 +738,7 @@ export default class Group extends AbleObject<GroupData> {
610
738
  }
611
739
  const test = await this.$nymph.getEntity(
612
740
  { class: tilmeld.Group, skipAc: true },
613
- selector
741
+ selector,
614
742
  );
615
743
  if (test != null) {
616
744
  return {
@@ -627,11 +755,64 @@ export default class Group extends AbleObject<GroupData> {
627
755
  }
628
756
 
629
757
  public async $save() {
630
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
758
+ const tilmeld = enforceTilmeld(this);
631
759
  if (this.$data.groupname == null || !this.$data.groupname.trim().length) {
632
760
  return false;
633
761
  }
634
762
 
763
+ // Lowercase the domain part.
764
+ if (tilmeld.config.domainSupport) {
765
+ const [groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
766
+ if (domain) {
767
+ this.$data.groupname = `${groupname}@${domain.toLowerCase()}`;
768
+ }
769
+ }
770
+
771
+ if (tilmeld.config.domainSupport) {
772
+ const [_groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
773
+ const [_originalGroupname, originalDomain] = splitn(
774
+ this.$originalGroupname ?? '',
775
+ '@',
776
+ 2,
777
+ );
778
+
779
+ if (
780
+ this.guid != null &&
781
+ ((originalDomain == null && domain != null) ||
782
+ (domain == null && originalDomain != null))
783
+ ) {
784
+ throw new AccessControlError(
785
+ "Groups can't switch to or from having a domain once created.",
786
+ );
787
+ }
788
+
789
+ if (
790
+ !this.$skipAcWhenSaving &&
791
+ !tilmeld.gatekeeper('tilmeld/admin') &&
792
+ !tilmeld.gatekeeper('system/admin')
793
+ ) {
794
+ if (
795
+ domain != null &&
796
+ !this.$is(tilmeld.currentUser) &&
797
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
798
+ ) {
799
+ throw new AccessControlError(
800
+ "You don't have the authority to modify groups on this domain.",
801
+ );
802
+ }
803
+ if (
804
+ originalDomain != null &&
805
+ originalDomain !== domain &&
806
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
807
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
808
+ ) {
809
+ throw new AccessControlError(
810
+ "You don't have the authority to modify groups on this domain.",
811
+ );
812
+ }
813
+ }
814
+ }
815
+
635
816
  // Formatting.
636
817
  this.$data.groupname = this.$data.groupname.trim();
637
818
  if (tilmeld.config.emailUsernames) {
@@ -643,16 +824,22 @@ export default class Group extends AbleObject<GroupData> {
643
824
  this.$data.enabled = !!this.$data.enabled;
644
825
 
645
826
  // Clear empty values.
646
- if (this.$data.name === '') {
827
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
647
828
  delete this.$data.name;
648
829
  }
649
- if (this.$data.email === '') {
830
+ if (
831
+ this.$data.email === '' ||
832
+ !tilmeld.config.userFields.includes('email')
833
+ ) {
650
834
  delete this.$data.email;
651
835
  }
652
836
  if (this.$data.avatar === '') {
653
837
  delete this.$data.avatar;
654
838
  }
655
- if (this.$data.phone === '') {
839
+ if (
840
+ this.$data.phone === '' ||
841
+ !tilmeld.config.userFields.includes('phone')
842
+ ) {
656
843
  delete this.$data.phone;
657
844
  }
658
845
  if (this.$data.parent == null) {
@@ -671,20 +858,33 @@ export default class Group extends AbleObject<GroupData> {
671
858
  delete this.$data.unverifiedSecondary;
672
859
  }
673
860
 
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[];
861
+ // Groups should never have 'system/admin', 'tilmeld/admin',
862
+ // 'tilmeld/switch', or domain admin abilities.
863
+ this.$data.abilities = (this.$data.abilities ?? []).filter((ability) => {
864
+ if (
865
+ ['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
866
+ -1
867
+ ) {
868
+ return false;
869
+ }
870
+ if (ability == null) {
871
+ return false;
872
+ }
873
+ if (ability.startsWith('tilmeld/domain/') && ability.endsWith('/admin')) {
874
+ return false;
875
+ }
876
+ return true;
877
+ }) as string[];
681
878
 
682
879
  // Verification.
683
880
  const unCheck = await this.$checkGroupname();
684
881
  if (!unCheck.result) {
685
882
  throw new BadUsernameError(unCheck.message);
686
883
  }
687
- if (!tilmeld.config.emailUsernames) {
884
+ if (
885
+ !tilmeld.config.emailUsernames &&
886
+ tilmeld.config.userFields.includes('email')
887
+ ) {
688
888
  const emCheck = await this.$checkEmail();
689
889
  if (!emCheck.result) {
690
890
  throw new BadEmailError(emCheck.message);
@@ -692,19 +892,21 @@ export default class Group extends AbleObject<GroupData> {
692
892
  }
693
893
 
694
894
  // Validate group parent. Make sure it's not a descendant of this group.
895
+ const parent = this.$data.parent;
896
+ await parent?.$wake();
695
897
  if (
696
- this.$data.parent &&
697
- (this.$data.parent.cdate == null ||
698
- this.$is(this.$data.parent) ||
699
- this.$data.parent.$isDescendant(this))
898
+ parent &&
899
+ (parent.cdate == null ||
900
+ this.$is(parent) ||
901
+ (await parent.$isDescendant(this)))
700
902
  ) {
701
903
  throw new BadDataError(
702
- "Group parent can't be itself or descendant of itself."
904
+ "Group parent can't be itself or descendant of itself.",
703
905
  );
704
906
  }
705
907
 
706
908
  try {
707
- tilmeld.config.validatorGroup(this);
909
+ tilmeld.config.validatorGroup(tilmeld, this);
708
910
  } catch (e: any) {
709
911
  throw new BadDataError(e?.message);
710
912
  }
@@ -713,20 +915,28 @@ export default class Group extends AbleObject<GroupData> {
713
915
  if (this.$data.defaultPrimary) {
714
916
  const currentPrimary = await this.$nymph.getEntity(
715
917
  { class: tilmeld.Group },
716
- { type: '&', truthy: 'defaultPrimary' }
918
+ { type: '&', truthy: 'defaultPrimary' },
717
919
  );
718
920
  if (currentPrimary != null && !this.$is(currentPrimary)) {
719
921
  currentPrimary.defaultPrimary = false;
720
- if (!(await currentPrimary.$save())) {
922
+ if (
923
+ this.$skipAcWhenSaving
924
+ ? !(await currentPrimary.$saveSkipAC())
925
+ : !(await currentPrimary.$save())
926
+ ) {
721
927
  throw new CouldNotChangeDefaultPrimaryGroupError(
722
928
  'Could not change new user primary group from ' +
723
- `${currentPrimary.groupname}.`
929
+ `${currentPrimary.groupname}.`,
724
930
  );
725
931
  }
726
932
  }
727
933
  }
728
934
 
729
- return await super.$save();
935
+ const ret = await super.$save();
936
+ if (ret) {
937
+ this.$originalGroupname = this.$data.groupname;
938
+ }
939
+ return ret;
730
940
  }
731
941
 
732
942
  /*
@@ -746,76 +956,118 @@ export default class Group extends AbleObject<GroupData> {
746
956
  }
747
957
 
748
958
  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.");
959
+ let tilmeld = enforceTilmeld(this);
960
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
961
+ throw new AccessControlError(
962
+ "You don't have the authority to delete groups.",
963
+ );
964
+ }
965
+ if (this.$data.groupname !== this.$originalGroupname) {
966
+ throw new AccessControlError(
967
+ "You can't delete a group while switching its groupname.",
968
+ );
752
969
  }
753
970
 
754
971
  const transaction = 'tilmeld-delete-' + this.guid;
755
972
  const nymph = this.$nymph;
756
973
  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())) {
974
+ this.$setNymph(tnymph);
975
+ tilmeld = enforceTilmeld(this);
976
+ const User = tnymph.getEntityClass(UserClass);
977
+
978
+ try {
979
+ // Delete descendants.
980
+ const descendants = await this.$getDescendants();
981
+ if (descendants.length) {
982
+ for (let curGroup of descendants) {
983
+ if (
984
+ this.$skipAcWhenDeleting
985
+ ? !(await curGroup.$deleteSkipAC())
986
+ : !(await curGroup.$delete())
987
+ ) {
988
+ await tnymph.rollback(transaction);
989
+ this.$setNymph(nymph);
990
+ return false;
991
+ }
992
+ }
993
+ }
994
+
995
+ // Remove users from this primary group.
996
+ const primaryUsers = await tnymph.getEntities(
997
+ {
998
+ class: User,
999
+ skipAc: true,
1000
+ },
1001
+ {
1002
+ type: '&',
1003
+ ref: ['group', this],
1004
+ },
1005
+ );
1006
+ for (let user of primaryUsers) {
1007
+ delete user.group;
1008
+ if (
1009
+ this.$skipAcWhenDeleting
1010
+ ? !(await user.$saveSkipAC())
1011
+ : !(await user.$save())
1012
+ ) {
765
1013
  await tnymph.rollback(transaction);
766
- this.$nymph = nymph;
1014
+ this.$setNymph(nymph);
767
1015
  return false;
768
1016
  }
769
1017
  }
770
- }
771
1018
 
772
- // 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
- }
782
- );
783
- for (let user of primaryUsers) {
784
- delete user.group;
785
- if (!(await user.$save())) {
786
- await tnymph.rollback(transaction);
787
- this.$nymph = nymph;
788
- return false;
1019
+ // Remove users from this secondary group.
1020
+ const secondaryUsers = await tnymph.getEntities(
1021
+ {
1022
+ class: User,
1023
+ skipAc: true,
1024
+ },
1025
+ {
1026
+ type: '&',
1027
+ ref: ['groups', this],
1028
+ },
1029
+ );
1030
+ for (let user of secondaryUsers) {
1031
+ user.$delGroup(this);
1032
+ if (
1033
+ this.$skipAcWhenDeleting
1034
+ ? !(await user.$saveSkipAC())
1035
+ : !(await user.$save())
1036
+ ) {
1037
+ await tnymph.rollback(transaction);
1038
+ return false;
1039
+ }
789
1040
  }
790
- }
791
1041
 
792
- // 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
- }
802
- );
803
- for (let user of secondaryUsers) {
804
- user.$delGroup(this);
805
- if (!(await user.$save())) {
1042
+ // Delete the group.
1043
+ let success = await super.$delete();
1044
+ if (success) {
1045
+ success = await tnymph.commit(transaction);
1046
+ } else {
806
1047
  await tnymph.rollback(transaction);
807
- return false;
808
1048
  }
1049
+ this.$setNymph(nymph);
1050
+ return success;
1051
+ } catch (e: any) {
1052
+ await tnymph.rollback(transaction);
1053
+ this.$setNymph(nymph);
1054
+ throw e;
809
1055
  }
1056
+ }
810
1057
 
811
- // Delete the group.
812
- const success = await super.$delete();
813
- if (success) {
814
- await tnymph.commit(transaction);
815
- } else {
816
- await tnymph.rollback(transaction);
1058
+ /*
1059
+ * This should *never* be accessible on the client.
1060
+ */
1061
+ async $deleteSkipAC() {
1062
+ this.$skipAcWhenDeleting = true;
1063
+ return await this.$delete();
1064
+ }
1065
+
1066
+ $tilmeldDeleteSkipAC() {
1067
+ if (this.$skipAcWhenDeleting) {
1068
+ this.$skipAcWhenDeleting = false;
1069
+ return true;
817
1070
  }
818
- this.$nymph = nymph;
819
- return success;
1071
+ return false;
820
1072
  }
821
1073
  }