@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/User.ts CHANGED
@@ -1,46 +1,60 @@
1
- import {
1
+ import crypto from 'node:crypto';
2
+ import type {
3
+ Nymph,
2
4
  EntityData,
3
5
  EntityJson,
4
6
  EntityPatch,
5
7
  Selector,
6
8
  SerializedEntityData,
7
9
  } from '@nymphjs/nymph';
8
- import { EmailOptions } from 'email-templates';
9
- import strtotime from 'locutus/php/datetime/strtotime';
10
- import Base64 from 'crypto-js/enc-base64';
11
- import sha256 from 'crypto-js/sha256';
12
- import md5 from 'crypto-js/md5';
13
- import { difference } from 'lodash';
14
10
  import { humanSecret, nanoid } from '@nymphjs/guid';
15
-
16
- import type Tilmeld from './Tilmeld';
17
- import AbleObject from './AbleObject';
18
- import Group, { GroupData } from './Group';
11
+ import type { EmailOptions } from 'email-templates';
12
+ import strtotime from 'locutus/php/datetime/strtotime.js';
13
+ import { difference, xor } from 'lodash-es';
14
+ import { TOTP, Secret } from 'otpauth';
15
+ import { toDataURL } from 'qrcode';
16
+ import { splitn } from '@sciactive/splitn';
17
+
18
+ import { enforceTilmeld } from './enforceTilmeld.js';
19
+ import AbleObject from './AbleObject.js';
20
+ import GroupClass from './Group.js';
21
+ import type { GroupData } from './Group.js';
19
22
  import {
23
+ AccessControlError,
20
24
  BadDataError,
21
25
  BadEmailError,
22
26
  BadUsernameError,
23
27
  EmailChangeRateLimitExceededError,
24
- } from './errors';
28
+ } from './errors/index.js';
25
29
 
26
30
  export type EventType =
31
+ | 'checkUsername'
27
32
  | 'beforeRegister'
28
33
  | 'afterRegister'
29
34
  | 'beforeLogin'
30
35
  | 'afterLogin'
31
36
  | 'beforeLogout'
32
37
  | 'afterLogout';
38
+ /**
39
+ * This is run when the user has entered an otherwise valid username into the
40
+ * signup form. It should return a result, which when false will stop the
41
+ * process and return the included message, disallowing the username.
42
+ */
43
+ export type TilmeldCheckUsernameCallback = (
44
+ user: User & UserData,
45
+ data: { username: string },
46
+ ) => Promise<{ result: boolean; message?: string }>;
33
47
  /**
34
48
  * Theses are run before the user data checks, so the only checks before are
35
49
  * whether registration is allowed and whether the user is already registered.
36
50
  */
37
51
  export type TilmeldBeforeRegisterCallback = (
38
52
  user: User & UserData,
39
- data: { password: string; additionalData?: { [k: string]: any } }
53
+ data: { password: string; additionalData?: { [k: string]: any } },
40
54
  ) => Promise<void>;
41
55
  export type TilmeldAfterRegisterCallback = (
42
56
  user: User & UserData,
43
- result: { loggedin: boolean; message: string }
57
+ result: { loggedin: boolean; message: string },
44
58
  ) => Promise<void>;
45
59
  /**
46
60
  * These are run after the authentication checks, but before the login action.
@@ -51,20 +65,20 @@ export type TilmeldBeforeLoginCallback = (
51
65
  username: string;
52
66
  password: string;
53
67
  additionalData?: { [k: string]: any };
54
- }
68
+ },
55
69
  ) => Promise<void>;
56
70
  /**
57
71
  * This is run before the transaction is committed, and you can perform
58
72
  * additional functions on the transaction, which is available in `user.$nymph`.
59
73
  */
60
74
  export type TilmeldAfterLoginCallback = (
61
- user: User & UserData
75
+ user: User & UserData,
62
76
  ) => Promise<void>;
63
77
  export type TilmeldBeforeLogoutCallback = (
64
- user: User & UserData
78
+ user: User & UserData,
65
79
  ) => Promise<void>;
66
80
  export type TilmeldAfterLogoutCallback = (
67
- user: User & UserData
81
+ user: User & UserData,
68
82
  ) => Promise<void>;
69
83
 
70
84
  export type UserData = {
@@ -108,11 +122,11 @@ export type UserData = {
108
122
  /**
109
123
  * The user's primary group.
110
124
  */
111
- group?: Group & GroupData;
125
+ group?: GroupClass & GroupData;
112
126
  /**
113
127
  * The user's secondary groups.
114
128
  */
115
- groups?: (Group & GroupData)[];
129
+ groups?: (GroupClass & GroupData)[];
116
130
  /**
117
131
  * Whether the user should inherit the abilities of his groups.
118
132
  */
@@ -135,7 +149,8 @@ export type UserData = {
135
149
  */
136
150
  newEmailSecret?: string;
137
151
  /**
138
- * The new email address.
152
+ * If the user has changed their email address, this is the new one, awaiting
153
+ * verification.
139
154
  */
140
155
  newEmailAddress?: string;
141
156
  /**
@@ -167,6 +182,15 @@ export type UserData = {
167
182
  * database.
168
183
  */
169
184
  passwordTemp?: string;
185
+ /**
186
+ * If set, this timestamp is the cutoff point for JWT issue dates. Any token
187
+ * issued before this date will not authenticate the user.
188
+ */
189
+ revokeTokenDate?: number;
190
+ /**
191
+ * Two factor auth secret.
192
+ */
193
+ totpSecret?: string;
170
194
  };
171
195
 
172
196
  /**
@@ -182,9 +206,9 @@ export default class User extends AbleObject<UserData> {
182
206
  /**
183
207
  * The instance of Tilmeld to use for queries.
184
208
  */
185
- static tilmeld: Tilmeld;
186
209
  static ETYPE = 'tilmeld_user';
187
210
  static class = 'User';
211
+ private static checkUsernameCallbacks: TilmeldCheckUsernameCallback[] = [];
188
212
  private static beforeRegisterCallbacks: TilmeldBeforeRegisterCallback[] = [];
189
213
  private static afterRegisterCallbacks: TilmeldAfterRegisterCallback[] = [];
190
214
  private static beforeLoginCallbacks: TilmeldBeforeLoginCallback[] = [];
@@ -200,23 +224,33 @@ export default class User extends AbleObject<UserData> {
200
224
  '$register',
201
225
  ];
202
226
  private static DEFAULT_PRIVATE_DATA = [
227
+ // The things that are commented are there to show what is _not_ private.
228
+ 'abilities',
229
+ // 'username',
230
+ // 'nameFirst',
231
+ // 'nameMiddle',
232
+ // 'nameLast',
233
+ // 'name',
203
234
  'email',
204
- 'originalEmail',
235
+ // 'avatar',
205
236
  'phone',
206
237
  'group',
207
238
  'groups',
208
- 'abilities',
209
239
  'inheritAbilities',
210
- 'recoverSecret',
211
- 'recoverSecretDate',
212
- 'password',
213
- 'salt',
240
+ 'enabled',
214
241
  'secret',
215
- 'newEmailAddress',
242
+ 'emailChangeDate',
216
243
  'newEmailSecret',
217
- 'cancelEmailAddress',
244
+ 'newEmailAddress',
218
245
  'cancelEmailSecret',
219
- 'emailChangeDate',
246
+ 'cancelEmailAddress',
247
+ 'recoverSecret',
248
+ 'recoverSecretDate',
249
+ 'salt',
250
+ 'password',
251
+ 'passwordTemp',
252
+ 'revokeTokenDate',
253
+ 'totpSecret',
220
254
  ];
221
255
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
222
256
 
@@ -227,6 +261,7 @@ export default class User extends AbleObject<UserData> {
227
261
  'sendRecovery',
228
262
  'recover',
229
263
  'getClientConfig',
264
+ 'getDomainUsers',
230
265
  ];
231
266
  protected $privateData = [...User.DEFAULT_PRIVATE_DATA];
232
267
  public static searchRestrictedData = [...User.DEFAULT_PRIVATE_DATA];
@@ -241,13 +276,17 @@ export default class User extends AbleObject<UserData> {
241
276
  */
242
277
  private $gatekeeperCache?: { [k: string]: true };
243
278
  /**
244
- * This is explicitly used only during the registration proccess.
279
+ * This should only be used by the backend.
245
280
  */
246
281
  private $skipAcWhenSaving = false;
282
+ /**
283
+ * This should only be used by the backend.
284
+ */
285
+ private $skipAcWhenDeleting = false;
247
286
  /**
248
287
  * The user's group descendants.
249
288
  */
250
- private $descendantGroups?: (Group & GroupData)[];
289
+ private $descendantGroups?: (GroupClass & GroupData)[];
251
290
  /**
252
291
  * Used to save the current email address to send verification if it changes
253
292
  * from the frontend.
@@ -256,10 +295,10 @@ export default class User extends AbleObject<UserData> {
256
295
  * verification, don't set this.
257
296
  */
258
297
  public $originalEmail?: string;
259
-
260
- static async factory(guid?: string): Promise<User & UserData> {
261
- return (await super.factory(guid)) as User & UserData;
262
- }
298
+ /**
299
+ * Used to save the current username for domain admin permissions.
300
+ */
301
+ public $originalUsername?: string;
263
302
 
264
303
  static async factoryUsername(username?: string): Promise<User & UserData> {
265
304
  const entity = new this();
@@ -271,7 +310,7 @@ export default class User extends AbleObject<UserData> {
271
310
  {
272
311
  type: '&',
273
312
  ilike: ['username', username.replace(/([\\%_])/g, (s) => `\\${s}`)],
274
- }
313
+ },
275
314
  );
276
315
  if (entity != null) {
277
316
  return entity;
@@ -280,34 +319,18 @@ export default class User extends AbleObject<UserData> {
280
319
  return entity;
281
320
  }
282
321
 
283
- static factorySync(guid?: string): User & UserData {
284
- return super.factorySync(guid) as User & UserData;
285
- }
286
-
287
- constructor(guid?: string) {
288
- super(guid);
289
-
290
- if (this.guid == null) {
291
- // Defaults.
292
- this.$data.enabled = true;
293
- this.$data.abilities = [];
294
- this.$data.groups = [];
295
- this.$data.inheritAbilities = true;
296
- this.$updateDataProtection();
297
- }
298
- }
299
-
300
322
  public static current(returnObjectIfNotExist: true): User & UserData;
301
323
  public static current(
302
- returnObjectIfNotExist?: false
324
+ returnObjectIfNotExist?: false,
303
325
  ): (User & UserData) | null;
304
326
  public static current(
305
- returnObjectIfNotExist?: boolean
327
+ returnObjectIfNotExist?: boolean,
306
328
  ): (User & UserData) | null {
307
- if (this.tilmeld.currentUser == null) {
329
+ const tilmeld = enforceTilmeld(this);
330
+ if (tilmeld.currentUser == null) {
308
331
  return returnObjectIfNotExist ? this.factorySync() : null;
309
332
  }
310
- return this.tilmeld.currentUser;
333
+ return tilmeld.currentUser;
311
334
  }
312
335
 
313
336
  /**
@@ -320,7 +343,8 @@ export default class User extends AbleObject<UserData> {
320
343
  recoveryType: 'username' | 'password';
321
344
  account: string;
322
345
  }): Promise<{ result: boolean; message: string }> {
323
- if (!this.tilmeld.config.pwRecovery) {
346
+ const tilmeld = enforceTilmeld(this);
347
+ if (!tilmeld.config.pwRecovery) {
324
348
  return {
325
349
  result: false,
326
350
  message: 'Account recovery is not allowed.',
@@ -330,18 +354,15 @@ export default class User extends AbleObject<UserData> {
330
354
  let user: User & UserData;
331
355
  const options: EmailOptions = {};
332
356
 
333
- if (
334
- !this.tilmeld.config.emailUsernames &&
335
- data.recoveryType === 'username'
336
- ) {
357
+ if (!tilmeld.config.emailUsernames && data.recoveryType === 'username') {
337
358
  // Create a username recovery email.
338
359
 
339
360
  const getUser = await this.nymph.getEntity(
340
- { class: this.tilmeld.User, skipAc: true },
361
+ { class: tilmeld.User, skipAc: true },
341
362
  {
342
363
  type: '&',
343
364
  ilike: ['email', data.account.replace(/([\\%_])/g, (s) => `\\${s}`)],
344
- }
365
+ },
345
366
  );
346
367
 
347
368
  if (getUser == null) {
@@ -364,7 +385,7 @@ export default class User extends AbleObject<UserData> {
364
385
  } else if (data.recoveryType === 'password') {
365
386
  // Create a password recovery email.
366
387
 
367
- const getUser = await this.tilmeld.User.factoryUsername(data.account);
388
+ const getUser = await tilmeld.User.factoryUsername(data.account);
368
389
 
369
390
  if (getUser.guid == null) {
370
391
  return {
@@ -391,14 +412,19 @@ export default class User extends AbleObject<UserData> {
391
412
  };
392
413
  options.locals = {
393
414
  recoverCode: getUser.recoverSecret,
394
- timeLimit: this.tilmeld.config.pwRecoveryTimeLimit,
415
+ timeLimit: tilmeld.config.pwRecoveryTimeLimit,
395
416
  };
396
417
  } else {
397
418
  return { result: false, message: 'Invalid recovery type.' };
398
419
  }
399
420
 
421
+ this.nymph.config.debugLog(
422
+ 'tilmeld',
423
+ `User account recovery (${options.template}) request for "${user.username}".`,
424
+ );
425
+
400
426
  // Send the email.
401
- if (await this.tilmeld.config.sendEmail(this.tilmeld, options, user)) {
427
+ if (await tilmeld.config.sendEmail(tilmeld, options, user)) {
402
428
  return {
403
429
  result: true,
404
430
  message:
@@ -421,22 +447,23 @@ export default class User extends AbleObject<UserData> {
421
447
  secret: string;
422
448
  password: string;
423
449
  }): Promise<{ result: boolean; message: string }> {
424
- if (!this.tilmeld.config.pwRecovery) {
450
+ const tilmeld = enforceTilmeld(this);
451
+ if (!tilmeld.config.pwRecovery) {
425
452
  return {
426
453
  result: false,
427
454
  message: 'Account recovery is not allowed.',
428
455
  };
429
456
  }
430
457
 
431
- const user = await this.tilmeld.User.factoryUsername(data.username);
458
+ const user = await tilmeld.User.factoryUsername(data.username);
432
459
 
433
460
  if (
434
461
  user.guid == null ||
435
462
  user.recoverSecret == null ||
436
463
  data.secret !== user.recoverSecret ||
437
464
  strtotime(
438
- '+' + this.tilmeld.config.pwRecoveryTimeLimit,
439
- Math.floor((user.recoverSecretDate ?? 0) / 1000)
465
+ '+' + tilmeld.config.pwRecoveryTimeLimit,
466
+ Math.floor((user.recoverSecretDate ?? 0) / 1000),
440
467
  ) *
441
468
  1000 <
442
469
  Date.now()
@@ -451,6 +478,11 @@ export default class User extends AbleObject<UserData> {
451
478
  return { result: false, message: 'Password cannot be empty.' };
452
479
  }
453
480
 
481
+ this.nymph.config.debugLog(
482
+ 'tilmeld',
483
+ `User account recovery for "${user.username}".`,
484
+ );
485
+
454
486
  user.$password(data.password);
455
487
  delete user.recoverSecret;
456
488
  delete user.recoverSecretDate;
@@ -466,27 +498,31 @@ export default class User extends AbleObject<UserData> {
466
498
  }
467
499
 
468
500
  public static getClientConfig() {
501
+ const tilmeld = enforceTilmeld(this);
469
502
  return {
470
- regFields: this.tilmeld.config.regFields,
471
- userFields: this.tilmeld.config.userFields,
472
- emailUsernames: this.tilmeld.config.emailUsernames,
473
- allowRegistration: this.tilmeld.config.allowRegistration,
474
- allowUsernameChange: this.tilmeld.config.allowUsernameChange,
475
- pwRecovery: this.tilmeld.config.pwRecovery,
476
- verifyEmail: this.tilmeld.config.verifyEmail,
477
- unverifiedAccess: this.tilmeld.config.unverifiedAccess,
503
+ regFields: tilmeld.config.regFields,
504
+ userFields: tilmeld.config.userFields,
505
+ emailUsernames: tilmeld.config.emailUsernames,
506
+ domainSupport: tilmeld.config.domainSupport,
507
+ allowRegistration: tilmeld.config.allowRegistration,
508
+ allowUsernameChange: tilmeld.config.allowUsernameChange,
509
+ pwRecovery: tilmeld.config.pwRecovery,
510
+ verifyEmail: tilmeld.config.verifyEmail,
511
+ unverifiedAccess: tilmeld.config.unverifiedAccess,
478
512
  };
479
513
  }
480
514
 
481
515
  public static async loginUser(data: {
482
516
  username: string;
483
517
  password: string;
518
+ code?: string;
484
519
  additionalData?: { [k: string]: any };
485
520
  }) {
521
+ const tilmeld = enforceTilmeld(this);
486
522
  if (!('username' in data) || !data.username.length) {
487
523
  return { result: false, message: 'Incorrect login/password.' };
488
524
  }
489
- const user = await this.tilmeld.User.factoryUsername(data.username);
525
+ const user = await tilmeld.User.factoryUsername(data.username);
490
526
  const result: { result: boolean; message: string; user?: User & UserData } =
491
527
  await user.$login(data);
492
528
  if (result.result) {
@@ -496,25 +532,149 @@ export default class User extends AbleObject<UserData> {
496
532
  return result;
497
533
  }
498
534
 
535
+ /**
536
+ * Get the users in a domain. This is only accessible to the domain's admins.
537
+ */
538
+ public static async getDomainUsers(
539
+ domain: string,
540
+ options?: {
541
+ limit?: number;
542
+ offset?: number;
543
+ sort?: string;
544
+ reverse?: boolean;
545
+ },
546
+ ) {
547
+ const tilmeld = enforceTilmeld(this);
548
+
549
+ if (!tilmeld.config.domainSupport) {
550
+ throw new AccessControlError('Domain support is not enabled.');
551
+ }
552
+
553
+ if (!domain) {
554
+ throw new BadDataError('Domain is required.');
555
+ }
556
+
557
+ domain = domain.toLowerCase();
558
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
559
+ throw new BadDataError(tilmeld.config.validDomainRegexNotice);
560
+ }
561
+
562
+ if (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
563
+ throw new AccessControlError(
564
+ 'You do not have the authority to manage this domain.',
565
+ );
566
+ }
567
+
568
+ return await this.nymph.getEntities(
569
+ {
570
+ class: this.nymph.getEntityClass(User),
571
+ ...(options?.limit != null
572
+ ? {
573
+ limit: options.limit,
574
+ }
575
+ : {}),
576
+ ...(options?.offset != null
577
+ ? {
578
+ offset: options.offset,
579
+ }
580
+ : {}),
581
+ ...(options?.sort != null
582
+ ? {
583
+ sort: options.sort,
584
+ }
585
+ : {}),
586
+ ...(options?.reverse != null
587
+ ? {
588
+ reverse: options.reverse,
589
+ }
590
+ : {}),
591
+ },
592
+ {
593
+ type: '&',
594
+ like: ['username', `%@${domain.replace(/([\\%_])/g, (s) => `\\${s}`)}`],
595
+ },
596
+ );
597
+ }
598
+
599
+ constructor() {
600
+ super();
601
+
602
+ // Defaults.
603
+ this.$data.enabled = true;
604
+ this.$data.abilities = [];
605
+ this.$data.groups = [];
606
+ this.$data.inheritAbilities = true;
607
+ this.$updateDataProtection();
608
+ }
609
+
610
+ $setNymph(nymph: Nymph) {
611
+ this.$nymph = nymph;
612
+ if (!this.$asleep()) {
613
+ if (this.$data.group && this.$data.group.$nymph !== nymph) {
614
+ this.$data.group.$setNymph(nymph);
615
+ }
616
+ for (const group of this.$data.groups ?? []) {
617
+ if (group && group.$nymph !== nymph) {
618
+ group.$setNymph(nymph);
619
+ }
620
+ }
621
+ }
622
+ }
623
+
624
+ async $getUniques(): Promise<string[]> {
625
+ const tilmeld = enforceTilmeld(this);
626
+ const uniques = [`u:${this.$data.username}`];
627
+ if (
628
+ !tilmeld.config.emailUsernames &&
629
+ tilmeld.config.userFields.includes('email')
630
+ ) {
631
+ uniques.push(`e:${this.$data.email}`);
632
+
633
+ if (this.$data.newEmailAddress != null) {
634
+ uniques.push(`e:${this.$data.newEmailAddress}`);
635
+ }
636
+ }
637
+ return uniques;
638
+ }
639
+
499
640
  public async $login(data: {
500
641
  username: string;
501
642
  password: string;
643
+ code?: string;
502
644
  additionalData?: { [k: string]: any };
503
645
  }) {
504
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
646
+ const tilmeld = enforceTilmeld(this);
505
647
  if (this.guid == null) {
506
648
  return { result: false, message: 'Incorrect login/password.' };
507
649
  }
508
650
  if (!this.$data.enabled) {
509
651
  return { result: false, message: 'This user is disabled.' };
510
652
  }
511
- if (this.$gatekeeper()) {
653
+ if (await this.$gatekeeper()) {
512
654
  return { result: true, message: 'You are already logged in.' };
513
655
  }
514
656
  if (!this.$checkPassword(data.password)) {
515
657
  return { result: false, message: 'Incorrect login/password.' };
516
658
  }
517
659
 
660
+ if (this.$data.totpSecret != null) {
661
+ if (
662
+ data.code == null ||
663
+ typeof data.code !== 'string' ||
664
+ data.code.length !== 6
665
+ ) {
666
+ return {
667
+ result: false,
668
+ needTOTP: true,
669
+ message: 'You need to provide a 2FA code.',
670
+ };
671
+ }
672
+
673
+ if (!this.$checkTOTPCode(data.code)) {
674
+ return { result: false, message: 'Incorrect 2FA code.' };
675
+ }
676
+ }
677
+
518
678
  try {
519
679
  for (let callback of (this.constructor as typeof User)
520
680
  .beforeLoginCallbacks) {
@@ -529,8 +689,90 @@ export default class User extends AbleObject<UserData> {
529
689
  };
530
690
  }
531
691
 
692
+ this.$nymph.config.debugLog(
693
+ 'tilmeld',
694
+ `User login for "${this.$data.username}".`,
695
+ );
696
+
697
+ // Authentication was successful, attempt to login.
698
+ if (!(await tilmeld.login(this, true))) {
699
+ return { result: false, message: 'Incorrect login/password.' };
700
+ }
701
+
702
+ for (let callback of (this.constructor as typeof User)
703
+ .afterLoginCallbacks) {
704
+ if (callback) {
705
+ await callback(this);
706
+ }
707
+ }
708
+
709
+ // Login was successful.
710
+ return { result: true, message: 'You are logged in.' };
711
+ }
712
+
713
+ public async $switchUser(data?: { additionalData?: { [k: string]: any } }) {
714
+ const tilmeld = enforceTilmeld(this);
715
+ if (this.guid == null) {
716
+ return { result: false, message: 'Incorrect login/password.' };
717
+ }
718
+
719
+ let user = tilmeld.User.current();
720
+
721
+ if (!user || user.guid == null) {
722
+ return { result: false, message: "You're not logged in." };
723
+ }
724
+
725
+ const isCurrentUser = user != null && user.$is(this);
726
+
727
+ if (isCurrentUser) {
728
+ return { result: false, message: "You can't switch to yourself." };
729
+ }
730
+
731
+ const toAbilities = this.$data.abilities || [];
732
+ const fromAbilities = user.abilities || [];
733
+
734
+ if (
735
+ !fromAbilities.includes('tilmeld/switch') &&
736
+ !fromAbilities.includes('system/admin')
737
+ ) {
738
+ return {
739
+ result: false,
740
+ message: "You don't have permission to switch users.",
741
+ };
742
+ }
743
+
744
+ if (
745
+ toAbilities.includes('system/admin') ||
746
+ toAbilities.includes('tilmeld/admin')
747
+ ) {
748
+ return { result: false, message: "You can't switch to an admin." };
749
+ }
750
+
751
+ try {
752
+ for (let callback of (this.constructor as typeof User)
753
+ .beforeLoginCallbacks) {
754
+ if (callback) {
755
+ await callback(this, {
756
+ username: this.$data.username || '',
757
+ password: '',
758
+ ...(data || {}),
759
+ });
760
+ }
761
+ }
762
+ } catch (e: any) {
763
+ return {
764
+ result: false,
765
+ message: e.message,
766
+ };
767
+ }
768
+
769
+ this.$nymph.config.debugLog(
770
+ 'tilmeld',
771
+ `User login switch for "${user.username}" to "${this.$data.username}".`,
772
+ );
773
+
532
774
  // Authentication was successful, attempt to login.
533
- if (!tilmeld.login(this, true)) {
775
+ if (!(await tilmeld.loginSwitch(this, true))) {
534
776
  return { result: false, message: 'Incorrect login/password.' };
535
777
  }
536
778
 
@@ -550,7 +792,7 @@ export default class User extends AbleObject<UserData> {
550
792
  * @returns An object with a boolean 'result' entry and a 'message' entry.
551
793
  */
552
794
  public async $logout() {
553
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
795
+ const tilmeld = enforceTilmeld(this);
554
796
 
555
797
  try {
556
798
  for (let callback of (this.constructor as typeof User)
@@ -566,7 +808,12 @@ export default class User extends AbleObject<UserData> {
566
808
  };
567
809
  }
568
810
 
569
- tilmeld.logout();
811
+ this.$nymph.config.debugLog(
812
+ 'tilmeld',
813
+ `User logout for "${this.$data.username}".`,
814
+ );
815
+
816
+ await tilmeld.logout();
570
817
 
571
818
  for (let callback of (this.constructor as typeof User)
572
819
  .afterLogoutCallbacks) {
@@ -587,89 +834,176 @@ export default class User extends AbleObject<UserData> {
587
834
  }
588
835
  return (
589
836
  'https://secure.gravatar.com/avatar/' +
590
- md5(this.$data.email.trim().toLowerCase()).toString() +
837
+ crypto
838
+ .createHash('sha256')
839
+ .update(this.$data.email.trim().toLowerCase())
840
+ .digest('hex')
841
+ .toLowerCase() +
591
842
  '?d=identicon&s=40'
592
843
  );
593
844
  }
594
845
 
846
+ public $getGid() {
847
+ if (this.$data.group) {
848
+ return this.$data.group.guid;
849
+ }
850
+ return null;
851
+ }
852
+
853
+ public $getGids() {
854
+ if (this.$data.groups) {
855
+ return this.$data.groups
856
+ .map((group) => group.guid)
857
+ .filter((guid) => guid != null) as string[];
858
+ }
859
+ return null;
860
+ }
861
+
595
862
  /**
596
863
  * Get the user's group descendants.
597
864
  */
598
- public async $getDescendantGroups(): Promise<(Group & GroupData)[]> {
865
+ public async $getDescendantGroups(): Promise<(GroupClass & GroupData)[]> {
599
866
  if (this.$descendantGroups == null) {
600
867
  this.$descendantGroups = [];
601
868
  if (this.$data.group != null) {
869
+ await this.$data.group.$wake();
602
870
  this.$descendantGroups = await this.$data.group.$getDescendants();
603
871
  }
872
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
604
873
  for (let curGroup of this.$data.groups ?? []) {
605
874
  this.$descendantGroups = this.$descendantGroups?.concat(
606
- await curGroup.$getDescendants()
875
+ await curGroup.$getDescendants(),
607
876
  );
608
877
  }
609
878
  }
610
879
  return this.$descendantGroups;
611
880
  }
612
881
 
613
- /**
614
- * Get the user's group descendants.
615
- */
616
- public $getDescendantGroupsSync(): (Group & GroupData)[] {
617
- if (this.$descendantGroups == null) {
618
- this.$descendantGroups = [];
619
- if (this.$data.group != null) {
620
- this.$descendantGroups = this.$data.group.$getDescendantsSync();
621
- }
622
- for (let curGroup of this.$data.groups ?? []) {
623
- this.$descendantGroups = this.$descendantGroups?.concat(
624
- curGroup.$getDescendantsSync()
625
- );
882
+ public $jsonAcceptData(input: EntityJson, allowConflict = false) {
883
+ const tilmeld = enforceTilmeld(this);
884
+ this.$check();
885
+
886
+ if ('abilities' in input.data) {
887
+ this.$checkIncomingAbilities(input.data.abilities);
888
+ }
889
+
890
+ // If the user is a new domain user, the data protection needs to be updated
891
+ // before the data is accepted, or else some things won't be accepted
892
+ // correctly (enabled, passwordTemp).
893
+ let reloadDataProtection = false;
894
+ if (tilmeld.config.domainSupport) {
895
+ let [_username, domain] = splitn(input.data.username ?? '', '@', 2);
896
+ // Lowercase the domain part.
897
+ if (domain) {
898
+ domain = domain.toLowerCase();
626
899
  }
900
+ const user = tilmeld.User.current();
901
+ const isCurrentUser = user != null && user.$is(this);
902
+ // While loading, accessing abilities on the current user will infinitely
903
+ // loop, but if this is the current user, we can use its abilities.
904
+ const abilities =
905
+ (isCurrentUser || user == null
906
+ ? this.$data.abilities
907
+ : user?.abilities) ?? [];
908
+ const isNewUser = input.guid == null;
909
+ const isDomainUser =
910
+ !isCurrentUser &&
911
+ user != null &&
912
+ domain &&
913
+ abilities.includes(`tilmeld/domain/${domain}/admin`);
914
+
915
+ reloadDataProtection = !!(isNewUser && isDomainUser);
916
+ }
917
+
918
+ super.$jsonAcceptData(input, allowConflict);
919
+
920
+ if (reloadDataProtection) {
921
+ // Load it again after data protection is run.
922
+ this.$updateDataProtection();
923
+ super.$jsonAcceptData(input, allowConflict);
627
924
  }
628
- return this.$descendantGroups;
629
925
  }
630
926
 
631
- public $jsonAcceptData(input: EntityJson, allowConflict = false) {
632
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
633
- this.$referenceWake();
927
+ public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
928
+ this.$check();
929
+
930
+ if ('abilities' in patch.set) {
931
+ this.$checkIncomingAbilities(patch.set.abilities);
932
+ }
933
+
934
+ super.$jsonAcceptPatch(patch, allowConflict);
935
+ }
936
+
937
+ private $checkIncomingAbilities(incoming: any) {
938
+ const tilmeld = enforceTilmeld(this);
634
939
 
635
940
  if (
636
- 'abilities' in input.data &&
637
- input.data.abilities.indexOf('system/admin') !== -1 &&
638
- this.$data.abilities?.indexOf('system/admin') === -1 &&
639
- tilmeld.gatekeeper('tilmeld/admin') &&
640
- !tilmeld.gatekeeper('system/admin')
941
+ !Array.isArray(incoming) ||
942
+ incoming.find((ability) => typeof ability !== 'string')
943
+ ) {
944
+ throw new BadDataError('Abilities must be an array of strings.');
945
+ }
946
+
947
+ if (
948
+ incoming.includes('system/admin') !==
949
+ this.$data.abilities?.includes('system/admin') &&
950
+ (!tilmeld.currentUser ||
951
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
641
952
  ) {
642
953
  throw new BadDataError(
643
- "You don't have the authority to make this user a system admin."
954
+ "You don't have the authority to grant or revoke system/admin.",
644
955
  );
645
956
  }
646
957
 
647
- this.$originalEmail = this.$data.email;
648
- super.$jsonAcceptData(input, allowConflict);
649
- }
650
-
651
- public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
652
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
653
- this.$referenceWake();
958
+ if (
959
+ incoming.includes('tilmeld/admin') !==
960
+ this.$data.abilities?.includes('tilmeld/admin') &&
961
+ (!tilmeld.currentUser ||
962
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
963
+ ) {
964
+ throw new BadDataError(
965
+ "You don't have the authority to grant or revoke tilmeld/admin.",
966
+ );
967
+ }
654
968
 
655
969
  if (
656
- 'abilities' in patch.set &&
657
- patch.set.abilities.indexOf('system/admin') !== -1 &&
658
- this.$data.abilities?.indexOf('system/admin') === -1 &&
659
- tilmeld.gatekeeper('tilmeld/admin') &&
660
- !tilmeld.gatekeeper('system/admin')
970
+ incoming.includes('tilmeld/switch') !==
971
+ this.$data.abilities?.includes('tilmeld/switch') &&
972
+ (!tilmeld.currentUser ||
973
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
661
974
  ) {
662
975
  throw new BadDataError(
663
- "You don't have the authority to make this user a system admin."
976
+ "You don't have the authority to grant or revoke tilmeld/switch.",
664
977
  );
665
978
  }
666
979
 
667
- this.$originalEmail = this.$data.email;
668
- super.$jsonAcceptPatch(patch, allowConflict);
980
+ if (
981
+ xor(
982
+ incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
983
+ this.$data.abilities?.filter((ability: string) =>
984
+ ability.startsWith('tilmeld/'),
985
+ ) ?? [],
986
+ ).length &&
987
+ (!tilmeld.currentUser ||
988
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
989
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
990
+ ) {
991
+ throw new BadDataError(
992
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
993
+ );
994
+ }
669
995
  }
670
996
 
671
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
672
- super.$putData(data, sdata);
997
+ public $putData(
998
+ data: EntityData,
999
+ sdata?: SerializedEntityData,
1000
+ source?: 'server',
1001
+ ) {
1002
+ super.$putData(data, sdata, source);
1003
+ if (source === 'server') {
1004
+ this.$originalEmail = this.$data.email;
1005
+ this.$originalUsername = this.$data.username;
1006
+ }
673
1007
  this.$updateDataProtection();
674
1008
  }
675
1009
 
@@ -679,8 +1013,15 @@ export default class User extends AbleObject<UserData> {
679
1013
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
680
1014
  */
681
1015
  public $updateDataProtection(givenUser?: User & UserData) {
682
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1016
+ const tilmeld = enforceTilmeld(this);
683
1017
  let user = givenUser ?? tilmeld.User.current();
1018
+ let [_username, domain] = tilmeld.config.domainSupport
1019
+ ? splitn(this.$data.username ?? '', '@', 2)
1020
+ : [this.$data.username, null];
1021
+ // Lowercase the domain part.
1022
+ if (domain) {
1023
+ domain = domain.toLowerCase();
1024
+ }
684
1025
 
685
1026
  this.$clientEnabledMethods = [...User.DEFAULT_CLIENT_ENABLED_METHODS];
686
1027
  this.$privateData = [...User.DEFAULT_PRIVATE_DATA];
@@ -698,53 +1039,165 @@ export default class User extends AbleObject<UserData> {
698
1039
  ? this.$data.abilities
699
1040
  : user?.abilities) ?? [];
700
1041
  const isNewUser = this.guid == null;
1042
+ const isDomainUser =
1043
+ tilmeld.config.domainSupport &&
1044
+ !isCurrentUser &&
1045
+ user != null &&
1046
+ domain &&
1047
+ abilities.includes(`tilmeld/domain/${domain}/admin`);
701
1048
 
702
1049
  if (isCurrentUser) {
703
1050
  // Users can check to see what abilities they have.
704
1051
  this.$clientEnabledMethods.push('$gatekeeper');
705
1052
  this.$clientEnabledMethods.push('$changePassword');
1053
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
706
1054
  this.$clientEnabledMethods.push('$logout');
707
1055
  this.$clientEnabledMethods.push('$sendEmailVerification');
1056
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1057
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
1058
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
1059
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
1060
+ }
1061
+
1062
+ if (isDomainUser) {
1063
+ // Domain admins can check to see what abilities their users have.
1064
+ this.$clientEnabledMethods.push('$gatekeeper');
1065
+ this.$clientEnabledMethods.push('$changePassword');
1066
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
1067
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1068
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
1069
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
1070
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
708
1071
  }
709
1072
 
710
1073
  if (
711
1074
  user != null &&
712
- (abilities.indexOf('tilmeld/admin') !== -1 ||
713
- abilities.indexOf('system/admin') !== -1)
1075
+ (abilities.includes('tilmeld/admin') ||
1076
+ abilities.includes('system/admin'))
714
1077
  ) {
715
1078
  // Users who can edit other users can see most of their data.
716
- this.$privateData = ['password', 'salt'];
1079
+ this.$privateData = ['password', 'salt', 'totpSecret'];
1080
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1081
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
717
1082
  this.$allowlistData = undefined;
1083
+ } else if (isDomainUser) {
1084
+ // Domain admins can see their users' data, and edit some of it.
1085
+ if (tilmeld.config.allowUsernameChange || isNewUser) {
1086
+ this.$allowlistData.push('username');
1087
+ }
1088
+ this.$allowlistData.push('avatar');
1089
+ if (tilmeld.config.userFields.includes('name')) {
1090
+ this.$allowlistData.push('nameFirst');
1091
+ this.$allowlistData.push('nameMiddle');
1092
+ this.$allowlistData.push('nameLast');
1093
+ this.$allowlistData.push('name');
1094
+ }
1095
+ if (tilmeld.config.userFields.includes('email')) {
1096
+ this.$allowlistData.push('email');
1097
+ }
1098
+ if (tilmeld.config.userFields.includes('phone')) {
1099
+ this.$allowlistData.push('phone');
1100
+ }
1101
+ this.$allowlistData.push('enabled');
1102
+ this.$allowlistData.push('passwordTemp');
1103
+ this.$privateData = [
1104
+ // The things commented are there to show what is _not_ private.
1105
+ // 'abilities',
1106
+ // 'username',
1107
+ // 'nameFirst',
1108
+ // 'nameMiddle',
1109
+ // 'nameLast',
1110
+ // 'name',
1111
+ // 'email',
1112
+ // 'avatar',
1113
+ // 'phone',
1114
+ // 'group',
1115
+ // 'groups',
1116
+ // 'inheritAbilities',
1117
+ // 'enabled',
1118
+ 'secret',
1119
+ 'emailChangeDate',
1120
+ 'newEmailSecret',
1121
+ // 'newEmailAddress',
1122
+ 'cancelEmailSecret',
1123
+ 'cancelEmailAddress',
1124
+ 'recoverSecret',
1125
+ 'recoverSecretDate',
1126
+ 'salt',
1127
+ 'password',
1128
+ // 'passwordTemp',
1129
+ // 'revokeTokenDate',
1130
+ 'totpSecret',
1131
+ ];
718
1132
  } else if (isCurrentUser || isNewUser) {
719
1133
  // Users can see their own data, and edit some of it.
720
1134
  if (tilmeld.config.allowUsernameChange || isNewUser) {
721
1135
  this.$allowlistData.push('username');
722
1136
  }
723
1137
  this.$allowlistData.push('avatar');
724
- if (tilmeld.config.userFields.indexOf('name') !== -1) {
1138
+ if (tilmeld.config.userFields.includes('name')) {
725
1139
  this.$allowlistData.push('nameFirst');
726
1140
  this.$allowlistData.push('nameMiddle');
727
1141
  this.$allowlistData.push('nameLast');
728
1142
  this.$allowlistData.push('name');
729
1143
  }
730
- if (tilmeld.config.userFields.indexOf('email') !== -1) {
1144
+ if (tilmeld.config.userFields.includes('email')) {
731
1145
  this.$allowlistData.push('email');
732
1146
  }
733
- if (tilmeld.config.userFields.indexOf('phone') !== -1) {
1147
+ if (tilmeld.config.userFields.includes('phone')) {
734
1148
  this.$allowlistData.push('phone');
735
1149
  }
736
1150
  this.$privateData = [
1151
+ // The things commented are there to show what is _not_ private.
1152
+ // 'abilities',
1153
+ // 'username',
1154
+ // 'nameFirst',
1155
+ // 'nameMiddle',
1156
+ // 'nameLast',
1157
+ // 'name',
1158
+ // 'email',
1159
+ // 'avatar',
1160
+ // 'phone',
1161
+ // 'group',
1162
+ // 'groups',
1163
+ // 'inheritAbilities',
1164
+ 'enabled',
737
1165
  'secret',
1166
+ 'emailChangeDate',
738
1167
  'newEmailSecret',
739
- 'cancelEmailAddress',
1168
+ // 'newEmailAddress',
740
1169
  'cancelEmailSecret',
741
- 'emailChangeDate',
1170
+ 'cancelEmailAddress',
742
1171
  'recoverSecret',
743
1172
  'recoverSecretDate',
744
- 'password',
745
1173
  'salt',
1174
+ 'password',
1175
+ 'passwordTemp',
1176
+ // 'revokeTokenDate',
1177
+ 'totpSecret',
746
1178
  ];
747
1179
  }
1180
+
1181
+ if (
1182
+ user != null &&
1183
+ !isCurrentUser &&
1184
+ (abilities.includes('tilmeld/switch') ||
1185
+ abilities.includes('system/admin'))
1186
+ ) {
1187
+ // Users with this ability can switch to other users.
1188
+ this.$clientEnabledMethods.push('$switchUser');
1189
+ }
1190
+ }
1191
+
1192
+ public $updateGroupDataProtection() {
1193
+ if (this.$data.group != null && !this.$data.group.$asleep()) {
1194
+ this.$data.group.$updateDataProtection();
1195
+ }
1196
+ for (let group of this.$data.groups ?? []) {
1197
+ if (!group.$asleep()) {
1198
+ group.$updateDataProtection();
1199
+ }
1200
+ }
748
1201
  }
749
1202
 
750
1203
  /**
@@ -761,35 +1214,43 @@ export default class User extends AbleObject<UserData> {
761
1214
  * @param ability The ability.
762
1215
  * @returns True or false.
763
1216
  */
764
- public $gatekeeper(ability?: string): boolean {
765
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1217
+ public async $gatekeeper(ability?: string): Promise<boolean> {
1218
+ await this.$wake();
1219
+ const tilmeld = enforceTilmeld(this);
766
1220
  if (ability == null) {
767
1221
  return tilmeld.User.current()?.$is(this) ?? false;
768
1222
  }
769
1223
  // Check the cache to see if we've already checked this user.
770
1224
  if (this.$gatekeeperCache == null) {
771
- let abilities = this.$data.abilities ?? [];
772
- if (this.$data.inheritAbilities) {
773
- for (let curGroup of this.$data.groups ?? []) {
774
- if (curGroup.enabled) {
775
- abilities = abilities.concat(curGroup.abilities ?? []);
776
- }
777
- }
778
- if (
779
- this.$data.group != null &&
780
- this.$data.group.cdate != null &&
781
- this.$data.group.enabled
782
- ) {
783
- abilities = abilities.concat(this.$data.group.abilities ?? []);
784
- }
785
- }
786
- this.$gatekeeperCache = Object.fromEntries(
787
- abilities.map((ability) => [ability, true])
788
- );
1225
+ this.$gatekeeperCache = await this.$getGatekeeperCache();
789
1226
  }
790
1227
  return (
791
1228
  (ability in this.$gatekeeperCache && !!this.$gatekeeperCache[ability]) ||
792
- !!this.$gatekeeperCache['system/admin']
1229
+ !!this.$data.abilities?.includes('system/admin')
1230
+ );
1231
+ }
1232
+
1233
+ /**
1234
+ * Build a gatekeeper cache object.
1235
+ */
1236
+ public async $getGatekeeperCache() {
1237
+ await this.$wake();
1238
+ let abilities = this.$data.abilities ?? [];
1239
+ if (this.$data.inheritAbilities) {
1240
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1241
+ for (let curGroup of this.$data.groups ?? []) {
1242
+ if (curGroup.enabled) {
1243
+ abilities = abilities.concat(curGroup.abilities ?? []);
1244
+ }
1245
+ }
1246
+ await this.$data.group?.$wake();
1247
+ const group = this.$data.group;
1248
+ if (group != null && group.cdate != null && group.enabled) {
1249
+ abilities = abilities.concat(group.abilities ?? []);
1250
+ }
1251
+ }
1252
+ return Object.fromEntries(
1253
+ abilities.map((ability) => [ability, true as true]),
793
1254
  );
794
1255
  }
795
1256
 
@@ -806,7 +1267,7 @@ export default class User extends AbleObject<UserData> {
806
1267
  * @returns True on success, false on failure.
807
1268
  */
808
1269
  public async $sendEmailVerification() {
809
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1270
+ const tilmeld = enforceTilmeld(this);
810
1271
  if (this.guid == null) {
811
1272
  return false;
812
1273
  }
@@ -818,7 +1279,7 @@ export default class User extends AbleObject<UserData> {
818
1279
 
819
1280
  if (this.$data.secret != null) {
820
1281
  const link = `${verifyUrl}?action=verify&id=${encodeURIComponent(
821
- this.guid
1282
+ this.guid,
822
1283
  )}&secret=${encodeURIComponent(this.$data.secret)}`;
823
1284
  success =
824
1285
  success &&
@@ -836,13 +1297,13 @@ export default class User extends AbleObject<UserData> {
836
1297
  verifyLink: link,
837
1298
  },
838
1299
  },
839
- this
1300
+ this,
840
1301
  ));
841
1302
  }
842
1303
 
843
1304
  if (this.$data.newEmailSecret != null) {
844
1305
  const link = `${verifyUrl}?action=verifychange&id=${encodeURIComponent(
845
- this.guid
1306
+ this.guid,
846
1307
  )}&secret=${encodeURIComponent(this.$data.newEmailSecret)}`;
847
1308
  success =
848
1309
  success &&
@@ -862,13 +1323,13 @@ export default class User extends AbleObject<UserData> {
862
1323
  newEmail: this.$data.newEmailAddress,
863
1324
  },
864
1325
  },
865
- this
1326
+ this,
866
1327
  ));
867
1328
  }
868
1329
 
869
1330
  if (this.$data.cancelEmailSecret != null) {
870
1331
  const link = `${verifyUrl}?action=cancelchange&id=${encodeURIComponent(
871
- this.guid
1332
+ this.guid,
872
1333
  )}&secret=${encodeURIComponent(this.$data.cancelEmailSecret)}`;
873
1334
  success =
874
1335
  success &&
@@ -888,7 +1349,7 @@ export default class User extends AbleObject<UserData> {
888
1349
  newEmail: this.$data.newEmailAddress ?? this.$data.email,
889
1350
  },
890
1351
  },
891
- this
1352
+ this,
892
1353
  ));
893
1354
  }
894
1355
 
@@ -901,13 +1362,16 @@ export default class User extends AbleObject<UserData> {
901
1362
  * @param group The group.
902
1363
  * @returns True if the user is already in the group. The resulting array of groups if the user was not.
903
1364
  */
904
- public $addGroup(group: Group & GroupData) {
905
- if (this.$data.groups == null) {
906
- this.$data.groups = [];
1365
+ public async $addGroup(group: GroupClass & GroupData) {
1366
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1367
+ let groups = this.$data.groups;
1368
+ if (groups == null) {
1369
+ groups = [];
907
1370
  }
908
- if (!group.$inArray(this.$data.groups)) {
909
- this.$data.groups.push(group);
910
- return this.$data.groups;
1371
+ if (!group.$inArray(groups)) {
1372
+ groups.push(group);
1373
+ this.$data.groups = groups;
1374
+ return groups;
911
1375
  }
912
1376
  return true;
913
1377
  }
@@ -919,17 +1383,23 @@ export default class User extends AbleObject<UserData> {
919
1383
  * @returns True if the passwords match, otherwise false.
920
1384
  */
921
1385
  public $checkPassword(password: string) {
922
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1386
+ const tilmeld = enforceTilmeld(this);
923
1387
  switch (tilmeld.config.pwMethod) {
924
1388
  case 'plain':
925
1389
  return this.$data.password === password;
926
1390
  case 'digest':
927
- return this.$data.password === sha256(password).toString(Base64);
1391
+ return (
1392
+ this.$data.password ===
1393
+ crypto.createHash('sha256').update(password).digest('base64')
1394
+ );
928
1395
  case 'salt':
929
1396
  default:
930
1397
  return (
931
1398
  this.$data.password ===
932
- sha256(password + this.$data.salt).toString(Base64)
1399
+ crypto
1400
+ .createHash('sha256')
1401
+ .update(password + this.$data.salt)
1402
+ .digest('base64')
933
1403
  );
934
1404
  }
935
1405
  }
@@ -940,10 +1410,12 @@ export default class User extends AbleObject<UserData> {
940
1410
  * @param group The group.
941
1411
  * @returns True if the user wasn't in the group. The resulting array of groups if the user was.
942
1412
  */
943
- public $delGroup(group: Group & GroupData) {
944
- if (this.$data.groups != null && group.$inArray(this.$data.groups)) {
945
- const newGroups: (Group & GroupData)[] = [];
946
- for (let curGroup of this.$data.groups) {
1413
+ public async $delGroup(group: GroupClass & GroupData) {
1414
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1415
+ let groups = this.$data.groups || [];
1416
+ if (this.$data.groups != null && group.$inArray(groups)) {
1417
+ const newGroups: (GroupClass & GroupData)[] = [];
1418
+ for (let curGroup of groups) {
947
1419
  if (!group.$is(curGroup)) {
948
1420
  newGroups.push(curGroup);
949
1421
  }
@@ -960,7 +1432,7 @@ export default class User extends AbleObject<UserData> {
960
1432
  * @param group The group, or the group's GUID.
961
1433
  * @returns True or false.
962
1434
  */
963
- public $inGroup(group: (Group & GroupData) | string) {
1435
+ public async $inGroup(group: (GroupClass & GroupData) | string) {
964
1436
  if (typeof group === 'string') {
965
1437
  if (this.$data.group?.guid === group) {
966
1438
  return true;
@@ -983,16 +1455,19 @@ export default class User extends AbleObject<UserData> {
983
1455
  * @param group The group, or the group's GUID.
984
1456
  * @returns True or false.
985
1457
  */
986
- public $isDescendant(group: (Group & GroupData) | string) {
1458
+ public async $isDescendant(group: (GroupClass & GroupData) | string) {
987
1459
  // Check to see if the user is in a descendant group of the given group.
1460
+ await this.$data.group?.$wake();
988
1461
  if (
1462
+ this.$data.group != null &&
989
1463
  this.$data.group?.cdate != null &&
990
- this.$data.group.$isDescendant(group)
1464
+ (await this.$data.group?.$isDescendant(group))
991
1465
  ) {
992
1466
  return true;
993
1467
  }
1468
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
994
1469
  for (let curGroup of this.$data.groups ?? []) {
995
- if (curGroup.$isDescendant(group)) {
1470
+ if (await curGroup.$isDescendant(group)) {
996
1471
  return true;
997
1472
  }
998
1473
  }
@@ -1008,15 +1483,31 @@ export default class User extends AbleObject<UserData> {
1008
1483
  public async $changePassword(data: {
1009
1484
  newPassword: string;
1010
1485
  currentPassword: string;
1486
+ revokeCurrentTokens?: boolean;
1011
1487
  }): Promise<{ result: boolean; message: string }> {
1488
+ this.$nymph.config.debugLog(
1489
+ 'tilmeld',
1490
+ `Password change request for "${this.$data.username}".`,
1491
+ );
1012
1492
  if (!('newPassword' in data) || !data.newPassword.length) {
1013
1493
  return { result: false, message: 'Please specify a password.' };
1014
1494
  }
1015
1495
  if (!this.$checkPassword(data.currentPassword ?? '')) {
1496
+ this.$nymph.config.debugLog(
1497
+ 'tilmeld',
1498
+ `Incorrect password for change request for "${this.$data.username}".`,
1499
+ );
1016
1500
  return { result: false, message: 'Incorrect password.' };
1017
1501
  }
1018
1502
  this.$data.passwordTemp = data.newPassword;
1503
+ if (data.revokeCurrentTokens) {
1504
+ this.$data.revokeTokenDate = Date.now();
1505
+ }
1019
1506
  if (await this.$save()) {
1507
+ if (data.revokeCurrentTokens) {
1508
+ const tilmeld = enforceTilmeld(this);
1509
+ await tilmeld.login(this, true);
1510
+ }
1020
1511
  return { result: true, message: 'Your password has been changed.' };
1021
1512
  } else {
1022
1513
  return { result: false, message: "Couldn't save new password." };
@@ -1030,7 +1521,7 @@ export default class User extends AbleObject<UserData> {
1030
1521
  * @returns The resulting password or hash which is stored in the entity.
1031
1522
  */
1032
1523
  public $password(password: string) {
1033
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1524
+ const tilmeld = enforceTilmeld(this);
1034
1525
  switch (tilmeld.config.pwMethod) {
1035
1526
  case 'plain':
1036
1527
  delete this.$data.salt;
@@ -1038,34 +1529,275 @@ export default class User extends AbleObject<UserData> {
1038
1529
  break;
1039
1530
  case 'digest':
1040
1531
  delete this.$data.salt;
1041
- this.$data.password = sha256(password).toString(Base64);
1532
+ this.$data.password = crypto
1533
+ .createHash('sha256')
1534
+ .update(password)
1535
+ .digest('base64');
1042
1536
  break;
1043
1537
  case 'salt':
1044
1538
  default:
1045
1539
  this.$data.salt = nanoid();
1046
- this.$data.password = sha256(password + this.$data.salt).toString(
1047
- Base64
1048
- );
1540
+ this.$data.password = crypto
1541
+ .createHash('sha256')
1542
+ .update(password + this.$data.salt)
1543
+ .digest('base64');
1049
1544
  break;
1050
1545
  }
1051
1546
  return this.$data.password;
1052
1547
  }
1053
1548
 
1549
+ /**
1550
+ * A frontend accessible method to revoke all currently issued tokens.
1551
+ *
1552
+ * @param data The input data from the client.
1553
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1554
+ */
1555
+ public async $revokeCurrentTokens(data: {
1556
+ password: string;
1557
+ }): Promise<{ result: boolean; message: string }> {
1558
+ if (!this.$checkPassword(data.password ?? '')) {
1559
+ return { result: false, message: 'Incorrect password.' };
1560
+ }
1561
+
1562
+ this.$data.revokeTokenDate = Date.now();
1563
+
1564
+ if (await this.$save()) {
1565
+ const tilmeld = enforceTilmeld(this);
1566
+ await tilmeld.login(this, true);
1567
+ return {
1568
+ result: true,
1569
+ message: 'You have logged out of all other sessions.',
1570
+ };
1571
+ } else {
1572
+ return { result: false, message: "Couldn't save revocation date." };
1573
+ }
1574
+ }
1575
+
1576
+ public async $hasTOTPSecret() {
1577
+ return this.$data.totpSecret != null;
1578
+ }
1579
+
1580
+ /**
1581
+ * Check the given code against the user's TOTP secret.
1582
+ *
1583
+ * @param code The code in question.
1584
+ * @returns True if the code is valid, otherwise false.
1585
+ */
1586
+ public $checkTOTPCode(code: string) {
1587
+ if (this.$data.totpSecret == null) {
1588
+ return false;
1589
+ }
1590
+
1591
+ const tilmeld = enforceTilmeld(this);
1592
+
1593
+ const totp = new TOTP({
1594
+ issuer: tilmeld.config.appName,
1595
+ label: this.$data.username,
1596
+ algorithm: 'SHA1',
1597
+ digits: 6,
1598
+ period: 30,
1599
+ secret: this.$data.totpSecret,
1600
+ });
1601
+
1602
+ return totp.validate({ token: code, window: 2 }) != null;
1603
+ }
1604
+
1605
+ /**
1606
+ * A frontend accessible method to generate a new TOTP secret.
1607
+ *
1608
+ * @returns An object with 'uri', 'qrcode', and 'secret'.
1609
+ */
1610
+ public async $getNewTOTPSecret() {
1611
+ const tilmeld = enforceTilmeld(this);
1612
+
1613
+ if (this.$data.totpSecret != null) {
1614
+ throw new BadDataError('You already have a 2FA secret.');
1615
+ }
1616
+
1617
+ const secret = new Secret();
1618
+
1619
+ const totp = new TOTP({
1620
+ issuer: tilmeld.config.appName,
1621
+ label: this.$data.username,
1622
+ algorithm: 'SHA1',
1623
+ digits: 6,
1624
+ period: 30,
1625
+ secret,
1626
+ });
1627
+
1628
+ const uri = totp.toString();
1629
+
1630
+ const qrcode = await toDataURL(uri);
1631
+
1632
+ return {
1633
+ uri,
1634
+ qrcode,
1635
+ secret: secret.base32,
1636
+ };
1637
+ }
1638
+
1639
+ /**
1640
+ * A frontend accessible method to save a TOTP secret into the user's account.
1641
+ *
1642
+ * @param data The input data from the client.
1643
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1644
+ */
1645
+ public async $saveTOTPSecret(data: {
1646
+ password: string;
1647
+ secret: string;
1648
+ code: string;
1649
+ }): Promise<{ result: boolean; message: string }> {
1650
+ if (!this.$checkPassword(data.password ?? '')) {
1651
+ return { result: false, message: 'Incorrect password.' };
1652
+ }
1653
+
1654
+ if (this.$data.totpSecret != null) {
1655
+ return { result: false, message: 'You already have a 2FA secret.' };
1656
+ }
1657
+
1658
+ if (
1659
+ data.secret == null ||
1660
+ typeof data.secret !== 'string' ||
1661
+ data.secret === ''
1662
+ ) {
1663
+ return { result: false, message: '2FA secret is invalid.' };
1664
+ }
1665
+
1666
+ if (
1667
+ data.code == null ||
1668
+ typeof data.code !== 'string' ||
1669
+ data.code.length !== 6
1670
+ ) {
1671
+ return { result: false, message: '2FA code is invalid.' };
1672
+ }
1673
+
1674
+ const tilmeld = enforceTilmeld(this);
1675
+
1676
+ const totp = new TOTP({
1677
+ issuer: tilmeld.config.appName,
1678
+ label: this.$data.username,
1679
+ algorithm: 'SHA1',
1680
+ digits: 6,
1681
+ period: 30,
1682
+ secret: data.secret,
1683
+ });
1684
+
1685
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1686
+ return { result: false, message: '2FA code is incorrect.' };
1687
+ }
1688
+
1689
+ this.$data.totpSecret = data.secret;
1690
+
1691
+ if (await this.$save()) {
1692
+ return { result: true, message: 'Your two factor secret is now set.' };
1693
+ } else {
1694
+ return { result: false, message: "Couldn't save two factor secret." };
1695
+ }
1696
+ }
1697
+
1698
+ /**
1699
+ * A frontend accessible method to remove the TOTP secret from the user's
1700
+ * account.
1701
+ *
1702
+ * @param data The input data from the client.
1703
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1704
+ */
1705
+ public async $removeTOTPSecret(data?: {
1706
+ password: string;
1707
+ code: string;
1708
+ }): Promise<{ result: boolean; message: string }> {
1709
+ const tilmeld = enforceTilmeld(this);
1710
+
1711
+ if (data) {
1712
+ if (!this.$checkPassword(data.password ?? '')) {
1713
+ return { result: false, message: 'Incorrect password.' };
1714
+ }
1715
+
1716
+ if (
1717
+ data.code == null ||
1718
+ typeof data.code !== 'string' ||
1719
+ data.code.length !== 6
1720
+ ) {
1721
+ return { result: false, message: '2FA code is invalid.' };
1722
+ }
1723
+
1724
+ if (this.$data.totpSecret == null) {
1725
+ return { result: false, message: "You don't have a 2FA secret." };
1726
+ }
1727
+
1728
+ const totp = new TOTP({
1729
+ issuer: tilmeld.config.appName,
1730
+ label: this.$data.username,
1731
+ algorithm: 'SHA1',
1732
+ digits: 6,
1733
+ period: 30,
1734
+ secret: this.$data.totpSecret,
1735
+ });
1736
+
1737
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1738
+ return { result: false, message: '2FA code is incorrect.' };
1739
+ }
1740
+ } else if (!tilmeld.gatekeeper('tilmeld/admin')) {
1741
+ return {
1742
+ result: false,
1743
+ message: 'You must provide your password and 2FA code.',
1744
+ };
1745
+ }
1746
+
1747
+ delete this.$data.totpSecret;
1748
+
1749
+ if (await this.$save()) {
1750
+ return {
1751
+ result: true,
1752
+ message: 'Two factor secret has been removed.',
1753
+ };
1754
+ } else {
1755
+ return { result: false, message: "Couldn't remove two factor secret." };
1756
+ }
1757
+ }
1758
+
1054
1759
  /**
1055
1760
  * Check that a username is valid.
1056
1761
  *
1057
1762
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1058
1763
  */
1059
1764
  public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1060
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1765
+ const tilmeld = enforceTilmeld(this);
1766
+
1767
+ this.$nymph.config.debugInfo(
1768
+ 'tilmeld',
1769
+ `Username check "${this.$data.username}".`,
1770
+ );
1771
+
1061
1772
  if (!tilmeld.config.emailUsernames) {
1062
- if (this.$data.username == null || !this.$data.username.length) {
1063
- return { result: false, message: 'Please specify a username.' };
1773
+ let [username, domain] = tilmeld.config.domainSupport
1774
+ ? splitn(this.$data.username ?? '', '@', 2)
1775
+ : [this.$data.username, null];
1776
+
1777
+ // Lowercase the domain part.
1778
+ if (domain) {
1779
+ domain = domain.toLowerCase();
1780
+ this.$data.username = `${username}@${domain}`;
1064
1781
  }
1782
+
1065
1783
  if (
1066
- tilmeld.config.maxUsernameLength > 0 &&
1067
- this.$data.username.length > tilmeld.config.maxUsernameLength
1784
+ this.$data.username == null ||
1785
+ !this.$data.username.length ||
1786
+ username == null ||
1787
+ !username.length
1068
1788
  ) {
1789
+ return { result: false, message: 'Please specify a username.' };
1790
+ }
1791
+ if (username.length < tilmeld.config.minUsernameLength) {
1792
+ return {
1793
+ result: false,
1794
+ message:
1795
+ 'Usernames must be at least ' +
1796
+ tilmeld.config.minUsernameLength +
1797
+ ' characters.',
1798
+ };
1799
+ }
1800
+ if (username.length > tilmeld.config.maxUsernameLength) {
1069
1801
  return {
1070
1802
  result: false,
1071
1803
  message:
@@ -1076,22 +1808,38 @@ export default class User extends AbleObject<UserData> {
1076
1808
  }
1077
1809
 
1078
1810
  if (
1079
- difference(
1080
- this.$data.username.split(''),
1081
- tilmeld.config.validChars.split('')
1082
- ).length
1811
+ difference(username.split(''), tilmeld.config.validChars.split(''))
1812
+ .length
1083
1813
  ) {
1084
1814
  return {
1085
1815
  result: false,
1086
1816
  message: tilmeld.config.validCharsNotice,
1087
1817
  };
1088
1818
  }
1089
- if (!tilmeld.config.validRegex.test(this.$data.username)) {
1819
+ if (!tilmeld.config.validRegex.test(username)) {
1090
1820
  return {
1091
1821
  result: false,
1092
1822
  message: tilmeld.config.validRegexNotice,
1093
1823
  };
1094
1824
  }
1825
+ if (domain != null) {
1826
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
1827
+ return {
1828
+ result: false,
1829
+ message: tilmeld.config.validDomainRegexNotice,
1830
+ };
1831
+ }
1832
+ if (
1833
+ !this.$is(tilmeld.currentUser) &&
1834
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
1835
+ ) {
1836
+ return {
1837
+ result: false,
1838
+ message:
1839
+ "You don't have the authority to manage users on this domain.",
1840
+ };
1841
+ }
1842
+ }
1095
1843
 
1096
1844
  const selector: Selector = {
1097
1845
  type: '&',
@@ -1105,12 +1853,24 @@ export default class User extends AbleObject<UserData> {
1105
1853
  }
1106
1854
  const test = await this.$nymph.getEntity(
1107
1855
  { class: tilmeld.User, skipAc: true },
1108
- selector
1856
+ selector,
1109
1857
  );
1110
1858
  if (test != null) {
1111
1859
  return { result: false, message: 'That username is taken.' };
1112
1860
  }
1113
1861
 
1862
+ for (let callback of (this.constructor as typeof User)
1863
+ .checkUsernameCallbacks) {
1864
+ if (callback) {
1865
+ const result = await callback(this, {
1866
+ username: this.$data.username,
1867
+ });
1868
+ if (!result.result) {
1869
+ return { message: 'Username is not available.', ...result };
1870
+ }
1871
+ }
1872
+ }
1873
+
1114
1874
  return {
1115
1875
  result: true,
1116
1876
  message:
@@ -1121,10 +1881,16 @@ export default class User extends AbleObject<UserData> {
1121
1881
  if (this.$data.username == null || !this.$data.username.length) {
1122
1882
  return { result: false, message: 'Please specify an email.' };
1123
1883
  }
1124
- if (
1125
- tilmeld.config.maxUsernameLength > 0 &&
1126
- this.$data.username.length > tilmeld.config.maxUsernameLength
1127
- ) {
1884
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1885
+ return {
1886
+ result: false,
1887
+ message:
1888
+ 'Emails must be at least ' +
1889
+ tilmeld.config.minUsernameLength +
1890
+ ' characters.',
1891
+ };
1892
+ }
1893
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1128
1894
  return {
1129
1895
  result: false,
1130
1896
  message:
@@ -1144,7 +1910,13 @@ export default class User extends AbleObject<UserData> {
1144
1910
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1145
1911
  */
1146
1912
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
1147
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1913
+ const tilmeld = enforceTilmeld(this);
1914
+
1915
+ this.$nymph.config.debugInfo(
1916
+ 'tilmeld',
1917
+ `Email check "${this.$data.email}".`,
1918
+ );
1919
+
1148
1920
  if (this.$data.email == null || !this.$data.email.length) {
1149
1921
  if (tilmeld.config.verifyEmail) {
1150
1922
  return { result: false, message: 'Please specify an email.' };
@@ -1167,7 +1939,7 @@ export default class User extends AbleObject<UserData> {
1167
1939
  }
1168
1940
  const test = await this.$nymph.getEntity(
1169
1941
  { class: tilmeld.User, skipAc: true },
1170
- selector
1942
+ selector,
1171
1943
  );
1172
1944
  if (test != null) {
1173
1945
  return {
@@ -1189,7 +1961,13 @@ export default class User extends AbleObject<UserData> {
1189
1961
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1190
1962
  */
1191
1963
  public async $checkPhone(): Promise<{ result: boolean; message: string }> {
1192
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1964
+ const tilmeld = enforceTilmeld(this);
1965
+
1966
+ this.$nymph.config.debugInfo(
1967
+ 'tilmeld',
1968
+ `Phone check "${this.$data.phone}".`,
1969
+ );
1970
+
1193
1971
  if (this.$data.phone == null || !this.$data.phone.length) {
1194
1972
  return { result: false, message: 'Please specify a phone number.' };
1195
1973
  }
@@ -1211,7 +1989,7 @@ export default class User extends AbleObject<UserData> {
1211
1989
  }
1212
1990
  const test = await this.$nymph.getEntity(
1213
1991
  { class: tilmeld.User, skipAc: true },
1214
- selector
1992
+ selector,
1215
1993
  );
1216
1994
  if (test != null) {
1217
1995
  return { result: false, message: 'Phone number is in use.' };
@@ -1228,7 +2006,7 @@ export default class User extends AbleObject<UserData> {
1228
2006
  password: string;
1229
2007
  additionalData?: { [k: string]: any };
1230
2008
  }): Promise<{ result: boolean; loggedin: boolean; message: string }> {
1231
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
2009
+ let tilmeld = enforceTilmeld(this);
1232
2010
  if (!tilmeld.config.allowRegistration) {
1233
2011
  return {
1234
2012
  result: false,
@@ -1244,21 +2022,6 @@ export default class User extends AbleObject<UserData> {
1244
2022
  };
1245
2023
  }
1246
2024
 
1247
- try {
1248
- for (let callback of (this.constructor as typeof User)
1249
- .beforeRegisterCallbacks) {
1250
- if (callback) {
1251
- await callback(this, data);
1252
- }
1253
- }
1254
- } catch (e: any) {
1255
- return {
1256
- result: false,
1257
- loggedin: false,
1258
- message: e.message,
1259
- };
1260
- }
1261
-
1262
2025
  if (!('password' in data) || !data.password.length) {
1263
2026
  return {
1264
2027
  result: false,
@@ -1272,7 +2035,7 @@ export default class User extends AbleObject<UserData> {
1272
2035
  }
1273
2036
 
1274
2037
  this.$password(data.password);
1275
- if (tilmeld.config.regFields.indexOf('name') !== -1) {
2038
+ if (tilmeld.config.regFields.includes('name')) {
1276
2039
  this.$data.name =
1277
2040
  this.$data.nameFirst +
1278
2041
  (this.$data.nameMiddle == null ? '' : ' ' + this.$data.nameMiddle) +
@@ -1289,75 +2052,34 @@ export default class User extends AbleObject<UserData> {
1289
2052
  const transaction = 'tilmeld-register-' + nanoid();
1290
2053
  const nymph = this.$nymph;
1291
2054
  const tnymph = await this.$nymph.startTransaction(transaction);
1292
- this.$nymph = tnymph;
1293
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2055
+ this.$setNymph(tnymph);
2056
+ tilmeld = enforceTilmeld(this);
2057
+
2058
+ let message = '';
2059
+ let loggedin = false;
1294
2060
 
1295
2061
  try {
1296
- // Add primary group.
1297
- let generatedPrimaryGroup: (Group & GroupData) | null = null;
1298
- if (tilmeld.config.generatePrimary) {
1299
- // Generate a new primary group for the user.
1300
- generatedPrimaryGroup = await tilmeld.Group.factory();
1301
- generatedPrimaryGroup.groupname = this.$data.username;
1302
- generatedPrimaryGroup.avatar = this.$data.avatar;
1303
- generatedPrimaryGroup.name = this.$data.name;
1304
- generatedPrimaryGroup.email = this.$data.email;
1305
- const parent = await tnymph.getEntity(
1306
- { class: tilmeld.Group },
1307
- {
1308
- type: '&',
1309
- equal: ['defaultPrimary', true],
1310
- }
1311
- );
1312
- if (parent != null) {
1313
- generatedPrimaryGroup.parent = parent;
1314
- }
1315
- if (!(await generatedPrimaryGroup.$saveSkipAC())) {
1316
- await tnymph.rollback(transaction);
1317
- this.$nymph = nymph;
1318
- return {
1319
- result: false,
1320
- loggedin: false,
1321
- message: 'Error creating primary group for user.',
1322
- };
1323
- }
1324
- this.$data.group = generatedPrimaryGroup;
1325
- } else {
1326
- // Add the default primary.
1327
- const group = await tnymph.getEntity(
1328
- { class: tilmeld.Group },
1329
- {
1330
- type: '&',
1331
- equal: ['defaultPrimary', true],
2062
+ try {
2063
+ for (let callback of (this.constructor as typeof User)
2064
+ .beforeRegisterCallbacks) {
2065
+ if (callback) {
2066
+ await callback(this, data);
1332
2067
  }
1333
- );
1334
- if (group != null) {
1335
- this.$data.group = group;
1336
2068
  }
2069
+ } catch (e: any) {
2070
+ await tnymph.rollback(transaction);
2071
+ this.$setNymph(nymph);
2072
+ return {
2073
+ result: false,
2074
+ loggedin: false,
2075
+ message: e.message,
2076
+ };
1337
2077
  }
1338
2078
 
1339
- // Add secondary groups.
1340
- if (tilmeld.config.verifyEmail && tilmeld.config.unverifiedAccess) {
1341
- // Add the default secondaries for unverified users.
1342
- this.$data.groups = await tnymph.getEntities(
1343
- { class: tilmeld.Group },
1344
- {
1345
- type: '&',
1346
- equal: ['unverifiedSecondary', true],
1347
- }
1348
- );
1349
- } else {
1350
- // Add the default secondaries.
1351
- this.$data.groups = await tnymph.getEntities(
1352
- { class: tilmeld.Group },
1353
- {
1354
- type: '&',
1355
- equal: ['defaultSecondary', true],
1356
- }
1357
- );
1358
- }
1359
-
1360
- if (tilmeld.config.verifyEmail) {
2079
+ if (
2080
+ tilmeld.config.userFields.includes('email') &&
2081
+ tilmeld.config.verifyEmail
2082
+ ) {
1361
2083
  // The user will be enabled after verifying their e-mail address.
1362
2084
  if (!tilmeld.config.unverifiedAccess) {
1363
2085
  this.$data.enabled = false;
@@ -1366,7 +2088,7 @@ export default class User extends AbleObject<UserData> {
1366
2088
  this.$data.enabled = true;
1367
2089
  }
1368
2090
 
1369
- // If create_admin is true and there are no other users, grant
2091
+ // If createAdmin is true and there are no other users, grant
1370
2092
  // "system/admin".
1371
2093
  let madeAdmin = false;
1372
2094
  if (tilmeld.config.createAdmin) {
@@ -1384,7 +2106,15 @@ export default class User extends AbleObject<UserData> {
1384
2106
  }
1385
2107
  }
1386
2108
 
2109
+ this.$nymph.config.debugLog(
2110
+ 'tilmeld',
2111
+ `Registering new user "${this.$data.username}".`,
2112
+ );
1387
2113
  if (await this.$saveSkipAC()) {
2114
+ this.$nymph.config.debugLog(
2115
+ 'tilmeld',
2116
+ `New user registered "${this.$data.username}".`,
2117
+ );
1388
2118
  // Send the new user registered email.
1389
2119
  if (tilmeld.config.userRegisteredRecipient != null) {
1390
2120
  await tilmeld.config.sendEmail(
@@ -1403,33 +2133,16 @@ export default class User extends AbleObject<UserData> {
1403
2133
  userPhone: this.$data.phone,
1404
2134
  },
1405
2135
  },
1406
- this
2136
+ this,
1407
2137
  );
1408
2138
  }
1409
2139
 
1410
- let message = '';
1411
- let loggedin = false;
1412
-
1413
- // Save the primary group.
1414
- if (generatedPrimaryGroup != null) {
1415
- generatedPrimaryGroup.user = this;
1416
- if (!(await generatedPrimaryGroup.$saveSkipAC())) {
1417
- await tnymph.rollback(transaction);
1418
- this.$nymph = nymph;
1419
- return {
1420
- result: false,
1421
- loggedin: false,
1422
- message:
1423
- 'Your primary group could not be assigned. Please try again later.',
1424
- };
1425
- }
1426
- }
1427
-
1428
2140
  // Finish up.
1429
2141
  if (
1430
2142
  tilmeld.config.verifyEmail &&
1431
2143
  !tilmeld.config.unverifiedAccess &&
1432
- !madeAdmin
2144
+ !madeAdmin &&
2145
+ tilmeld.config.userFields.includes('email')
1433
2146
  ) {
1434
2147
  message +=
1435
2148
  `Almost there. An email has been sent to ${this.$data.email} ` +
@@ -1437,9 +2150,10 @@ export default class User extends AbleObject<UserData> {
1437
2150
  } else if (
1438
2151
  tilmeld.config.verifyEmail &&
1439
2152
  tilmeld.config.unverifiedAccess &&
1440
- !madeAdmin
2153
+ !madeAdmin &&
2154
+ tilmeld.config.userFields.includes('email')
1441
2155
  ) {
1442
- if (!tilmeld.login(this, true)) {
2156
+ if (!(await tilmeld.login(this, true))) {
1443
2157
  throw new Error('An error occurred trying to log you in.');
1444
2158
  }
1445
2159
  message +=
@@ -1448,7 +2162,7 @@ export default class User extends AbleObject<UserData> {
1448
2162
  'registration.';
1449
2163
  loggedin = true;
1450
2164
  } else {
1451
- if (!tilmeld.login(this, true)) {
2165
+ if (!(await tilmeld.login(this, true))) {
1452
2166
  throw new Error('An error occurred trying to log you in.');
1453
2167
  }
1454
2168
  message += "You're now registered and logged in!";
@@ -1464,23 +2178,13 @@ export default class User extends AbleObject<UserData> {
1464
2178
  });
1465
2179
  }
1466
2180
  }
1467
-
1468
- try {
1469
- await tnymph.commit(transaction);
1470
- this.$nymph = nymph;
1471
- this.$nymph.tilmeld?.fillSession(this);
1472
- } catch (e: any) {
1473
- throw e;
1474
- }
1475
-
1476
- return {
1477
- result: true,
1478
- loggedin,
1479
- message,
1480
- };
1481
2181
  } else {
2182
+ this.$nymph.config.debugError(
2183
+ 'tilmeld',
2184
+ `Error registering new user "${this.$data.username}".`,
2185
+ );
1482
2186
  await tnymph.rollback(transaction);
1483
- this.$nymph = nymph;
2187
+ this.$setNymph(nymph);
1484
2188
  return {
1485
2189
  result: false,
1486
2190
  loggedin: false,
@@ -1488,28 +2192,100 @@ export default class User extends AbleObject<UserData> {
1488
2192
  };
1489
2193
  }
1490
2194
  } catch (e: any) {
2195
+ this.$nymph.config.debugError(
2196
+ 'tilmeld',
2197
+ `Error registering new user "${this.$data.username}": ${e}`,
2198
+ );
1491
2199
  await tnymph.rollback(transaction);
1492
- this.$nymph = nymph;
2200
+ this.$setNymph(nymph);
2201
+ throw e;
2202
+ }
2203
+
2204
+ try {
2205
+ await tnymph.commit(transaction);
2206
+ this.$setNymph(nymph);
2207
+ await this.$nymph.tilmeld?.fillSession(this);
2208
+ } catch (e: any) {
1493
2209
  throw e;
1494
2210
  }
2211
+
2212
+ return {
2213
+ result: true,
2214
+ loggedin,
2215
+ message,
2216
+ };
1495
2217
  }
1496
2218
 
1497
2219
  public async $save() {
1498
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
2220
+ let tilmeld = enforceTilmeld(this);
1499
2221
  if (this.$data.username == null || !this.$data.username.trim().length) {
1500
2222
  return false;
1501
2223
  }
1502
2224
 
1503
2225
  if (
2226
+ !this.$skipAcWhenSaving &&
1504
2227
  tilmeld.gatekeeper('tilmeld/admin') &&
1505
2228
  !tilmeld.gatekeeper('system/admin') &&
1506
- this.$gatekeeper('system/admin')
2229
+ this.$data.abilities?.includes('system/admin')
1507
2230
  ) {
1508
- throw new BadDataError(
1509
- "You don't have the authority to modify system admins."
2231
+ throw new AccessControlError(
2232
+ "You don't have the authority to modify system admins.",
1510
2233
  );
1511
2234
  }
1512
2235
 
2236
+ // Lowercase the domain part.
2237
+ if (tilmeld.config.domainSupport) {
2238
+ const [username, domain] = splitn(this.$data.username ?? '', '@', 2);
2239
+ if (domain) {
2240
+ this.$data.username = `${username}@${domain.toLowerCase()}`;
2241
+ }
2242
+ }
2243
+
2244
+ if (tilmeld.config.domainSupport) {
2245
+ const [_username, domain] = splitn(this.$data.username ?? '', '@', 2);
2246
+ const [_originalUsername, originalDomain] = splitn(
2247
+ this.$originalUsername ?? '',
2248
+ '@',
2249
+ 2,
2250
+ );
2251
+
2252
+ if (
2253
+ this.guid != null &&
2254
+ ((originalDomain == null && domain != null) ||
2255
+ (domain == null && originalDomain != null))
2256
+ ) {
2257
+ throw new AccessControlError(
2258
+ "Users can't switch to or from having a domain once created.",
2259
+ );
2260
+ }
2261
+
2262
+ if (
2263
+ !this.$skipAcWhenSaving &&
2264
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2265
+ !tilmeld.gatekeeper('system/admin')
2266
+ ) {
2267
+ if (
2268
+ domain != null &&
2269
+ !this.$is(tilmeld.currentUser) &&
2270
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
2271
+ ) {
2272
+ throw new AccessControlError(
2273
+ "You don't have the authority to modify users on this domain.",
2274
+ );
2275
+ }
2276
+ if (
2277
+ originalDomain != null &&
2278
+ originalDomain !== domain &&
2279
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
2280
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
2281
+ ) {
2282
+ throw new AccessControlError(
2283
+ "You don't have the authority to modify users on this domain.",
2284
+ );
2285
+ }
2286
+ }
2287
+ }
2288
+
1513
2289
  let sendVerification = false;
1514
2290
 
1515
2291
  // Formatting.
@@ -1528,49 +2304,88 @@ export default class User extends AbleObject<UserData> {
1528
2304
  this.$data.enabled = !!this.$data.enabled;
1529
2305
 
1530
2306
  // Clear empty values.
1531
- if (this.$data.nameFirst === '') {
2307
+ if (
2308
+ this.$data.nameFirst === '' ||
2309
+ !tilmeld.config.userFields.includes('name')
2310
+ ) {
1532
2311
  delete this.$data.nameFirst;
1533
2312
  }
1534
- if (this.$data.nameMiddle === '') {
2313
+ if (
2314
+ this.$data.nameMiddle === '' ||
2315
+ !tilmeld.config.userFields.includes('name')
2316
+ ) {
1535
2317
  delete this.$data.nameMiddle;
1536
2318
  }
1537
- if (this.$data.nameLast === '') {
2319
+ if (
2320
+ this.$data.nameLast === '' ||
2321
+ !tilmeld.config.userFields.includes('name')
2322
+ ) {
1538
2323
  delete this.$data.nameLast;
1539
2324
  }
1540
- if (this.$data.name === '') {
2325
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
1541
2326
  delete this.$data.name;
1542
2327
  }
1543
- if (this.$data.email === '') {
2328
+ if (
2329
+ this.$data.email === '' ||
2330
+ !tilmeld.config.userFields.includes('email')
2331
+ ) {
1544
2332
  delete this.$data.email;
1545
2333
  }
1546
2334
  if (this.$data.avatar === '') {
1547
2335
  delete this.$data.avatar;
1548
2336
  }
1549
- if (this.$data.phone === '') {
2337
+ if (
2338
+ this.$data.phone === '' ||
2339
+ !tilmeld.config.userFields.includes('phone')
2340
+ ) {
1550
2341
  delete this.$data.phone;
1551
2342
  }
1552
- if (this.$data.secret === '') {
2343
+ if (
2344
+ this.$data.secret === '' ||
2345
+ !tilmeld.config.userFields.includes('email')
2346
+ ) {
1553
2347
  delete this.$data.secret;
1554
2348
  }
1555
- if (this.$data.emailChangeDate === 0) {
2349
+ if (
2350
+ this.$data.emailChangeDate === 0 ||
2351
+ !tilmeld.config.userFields.includes('email')
2352
+ ) {
1556
2353
  delete this.$data.emailChangeDate;
1557
2354
  }
1558
- if (this.$data.newEmailSecret === '') {
2355
+ if (
2356
+ this.$data.newEmailSecret === '' ||
2357
+ !tilmeld.config.userFields.includes('email')
2358
+ ) {
1559
2359
  delete this.$data.newEmailSecret;
1560
2360
  }
1561
- if (this.$data.newEmailAddress === '') {
2361
+ if (
2362
+ this.$data.newEmailAddress === '' ||
2363
+ !tilmeld.config.userFields.includes('email')
2364
+ ) {
1562
2365
  delete this.$data.newEmailAddress;
1563
2366
  }
1564
- if (this.$data.cancelEmailSecret === '') {
2367
+ if (
2368
+ this.$data.cancelEmailSecret === '' ||
2369
+ !tilmeld.config.userFields.includes('email')
2370
+ ) {
1565
2371
  delete this.$data.cancelEmailSecret;
1566
2372
  }
1567
- if (this.$data.cancelEmailAddress === '') {
2373
+ if (
2374
+ this.$data.cancelEmailAddress === '' ||
2375
+ !tilmeld.config.userFields.includes('email')
2376
+ ) {
1568
2377
  delete this.$data.cancelEmailAddress;
1569
2378
  }
1570
- if (this.$data.recoverSecret === '') {
2379
+ if (
2380
+ this.$data.recoverSecret === '' ||
2381
+ !tilmeld.config.userFields.includes('email')
2382
+ ) {
1571
2383
  delete this.$data.recoverSecret;
1572
2384
  }
1573
- if (this.$data.recoverSecretDate === 0) {
2385
+ if (
2386
+ this.$data.recoverSecretDate === 0 ||
2387
+ !tilmeld.config.userFields.includes('email')
2388
+ ) {
1574
2389
  delete this.$data.recoverSecretDate;
1575
2390
  }
1576
2391
  if (this.$data.password === '') {
@@ -1588,7 +2403,10 @@ export default class User extends AbleObject<UserData> {
1588
2403
  if (!unCheck.result) {
1589
2404
  throw new BadUsernameError(unCheck.message);
1590
2405
  }
1591
- if (!tilmeld.config.emailUsernames) {
2406
+ if (
2407
+ !tilmeld.config.emailUsernames &&
2408
+ tilmeld.config.userFields.includes('email')
2409
+ ) {
1592
2410
  const emCheck = await this.$checkEmail();
1593
2411
  if (!emCheck.result) {
1594
2412
  throw new BadEmailError(emCheck.message);
@@ -1596,7 +2414,10 @@ export default class User extends AbleObject<UserData> {
1596
2414
  }
1597
2415
 
1598
2416
  // Email changes.
1599
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
2417
+ if (
2418
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2419
+ tilmeld.config.userFields.includes('email')
2420
+ ) {
1600
2421
  // The user isn't an admin, so email address changes should contain some
1601
2422
  // security measures.
1602
2423
  if (tilmeld.config.verifyEmail) {
@@ -1604,7 +2425,7 @@ export default class User extends AbleObject<UserData> {
1604
2425
  if (this.guid == null) {
1605
2426
  // If this is the first user, they'll be made an admin, so they don't
1606
2427
  // need to verify.
1607
- if ((this.$data.abilities?.indexOf('system/admin') ?? -1) === -1) {
2428
+ if (!this.$data.abilities?.includes('system/admin')) {
1608
2429
  this.$data.secret = nanoid();
1609
2430
  sendVerification = true;
1610
2431
  }
@@ -1624,10 +2445,10 @@ export default class User extends AbleObject<UserData> {
1624
2445
  new Date(
1625
2446
  strtotime(
1626
2447
  '+' + tilmeld.config.emailRateLimit,
1627
- Math.floor(this.$data.emailChangeDate / 1000)
1628
- ) * 1000
2448
+ Math.floor(this.$data.emailChangeDate / 1000),
2449
+ ) * 1000,
1629
2450
  ).toString() +
1630
- ' to change your email address again.'
2451
+ ' to change your email address again.',
1631
2452
  );
1632
2453
  } else {
1633
2454
  if (
@@ -1682,40 +2503,126 @@ export default class User extends AbleObject<UserData> {
1682
2503
  throw new BadDataError('A password is required.');
1683
2504
  }
1684
2505
 
1685
- try {
1686
- tilmeld.config.validatorUser(this);
1687
- } catch (e: any) {
1688
- throw new BadDataError(e?.message);
1689
- }
1690
-
1691
2506
  // Start transaction.
1692
2507
  const transaction = 'tilmeld-save-' + nanoid();
1693
2508
  const nymph = this.$nymph;
1694
2509
  const tnymph = await this.$nymph.startTransaction(transaction);
1695
- this.$nymph = tnymph;
1696
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2510
+ this.$setNymph(tnymph);
2511
+ tilmeld = enforceTilmeld(this);
2512
+ const Group = tnymph.getEntityClass(GroupClass);
1697
2513
 
1698
- if (
1699
- this.$data.group != null &&
1700
- this.$data.group.user != null &&
1701
- this.$is(this.$data.group.user)
1702
- ) {
1703
- try {
2514
+ try {
2515
+ let group = this.$data.group;
2516
+ await group?.$wake();
2517
+ if (group == null && this.guid == null) {
2518
+ if (tilmeld.config.generatePrimary) {
2519
+ // Generate a new primary group for the user.
2520
+ group = await Group.factory();
2521
+ group.user = this;
2522
+ const parent = await tnymph.getEntity(
2523
+ { class: Group },
2524
+ {
2525
+ type: '&',
2526
+ equal: ['defaultPrimary', true],
2527
+ },
2528
+ );
2529
+ if (parent != null) {
2530
+ group.parent = parent;
2531
+ }
2532
+ } else {
2533
+ // Add the default primary.
2534
+ const group = await tnymph.getEntity(
2535
+ { class: Group },
2536
+ {
2537
+ type: '&',
2538
+ equal: ['defaultPrimary', true],
2539
+ },
2540
+ );
2541
+ if (group != null) {
2542
+ this.$data.group = group;
2543
+ }
2544
+ }
2545
+ }
2546
+ if (group != null && group.user != null && this.$is(group.user)) {
1704
2547
  // Update the user's generated primary group.
1705
- this.$data.group.groupname = this.$data.username;
1706
- this.$data.group.avatar = this.$data.avatar;
1707
- this.$data.group.email = this.$data.email;
1708
- this.$data.group.name = this.$data.name;
1709
- this.$data.group.phone = this.$data.phone;
1710
- await this.$data.group.$saveSkipAC();
2548
+ let changed = false;
2549
+ if (group.groupname !== this.$data.username) {
2550
+ group.groupname = this.$data.username;
2551
+ changed = true;
2552
+ }
2553
+ if (group.avatar !== this.$data.avatar) {
2554
+ group.avatar = this.$data.avatar;
2555
+ changed = true;
2556
+ }
2557
+ if (
2558
+ tilmeld.config.userFields.includes('email') &&
2559
+ group.email !== this.$data.email
2560
+ ) {
2561
+ group.email = this.$data.email;
2562
+ changed = true;
2563
+ }
2564
+ if (
2565
+ tilmeld.config.userFields.includes('name') &&
2566
+ group.name !== this.$data.name
2567
+ ) {
2568
+ group.name = this.$data.name;
2569
+ changed = true;
2570
+ }
2571
+ if (
2572
+ tilmeld.config.userFields.includes('phone') &&
2573
+ group.phone !== this.$data.phone
2574
+ ) {
2575
+ group.phone = this.$data.phone;
2576
+ changed = true;
2577
+ }
2578
+ if (changed || group.guid == null) {
2579
+ if (!(await group.$saveSkipAC())) {
2580
+ throw Error('Error updating primary group for user.');
2581
+ }
2582
+ }
2583
+ this.$data.group = group;
2584
+ }
2585
+
2586
+ if (this.$data.groups == null || this.$data.groups.length === 0) {
2587
+ // Add secondary groups.
2588
+ if (
2589
+ tilmeld.config.userFields.includes('email') &&
2590
+ tilmeld.config.verifyEmail &&
2591
+ tilmeld.config.unverifiedAccess &&
2592
+ this.$data.secret != null
2593
+ ) {
2594
+ // Add the default secondaries for unverified users.
2595
+ this.$data.groups = await tnymph.getEntities(
2596
+ { class: Group },
2597
+ {
2598
+ type: '&',
2599
+ equal: ['unverifiedSecondary', true],
2600
+ },
2601
+ );
2602
+ } else {
2603
+ // Add the default secondaries.
2604
+ this.$data.groups = await tnymph.getEntities(
2605
+ { class: Group },
2606
+ {
2607
+ type: '&',
2608
+ equal: ['defaultSecondary', true],
2609
+ },
2610
+ );
2611
+ }
2612
+ }
2613
+
2614
+ try {
2615
+ tilmeld.config.validatorUser(tilmeld, this);
1711
2616
  } catch (e: any) {
1712
- await tnymph.rollback(transaction);
1713
- this.$nymph = nymph;
1714
- throw e;
2617
+ throw new BadDataError(e?.message);
1715
2618
  }
2619
+ } catch (e: any) {
2620
+ await tnymph.rollback(transaction);
2621
+ this.$setNymph(nymph);
2622
+ throw e;
1716
2623
  }
1717
2624
 
1718
- let ret: boolean;
2625
+ let ret = false;
1719
2626
  let preGuid = this.guid;
1720
2627
  let preCdate = this.cdate;
1721
2628
  let preMdate = this.mdate;
@@ -1724,9 +2631,13 @@ export default class User extends AbleObject<UserData> {
1724
2631
  ret = await super.$save();
1725
2632
  } catch (e: any) {
1726
2633
  await tnymph.rollback(transaction);
1727
- this.$nymph = nymph;
2634
+ this.guid = preGuid;
2635
+ this.cdate = preCdate;
2636
+ this.mdate = preMdate;
2637
+ this.$setNymph(nymph);
1728
2638
  throw e;
1729
2639
  }
2640
+
1730
2641
  if (ret) {
1731
2642
  if (sendVerification) {
1732
2643
  // The email has changed, so send a new verification email.
@@ -1735,23 +2646,31 @@ export default class User extends AbleObject<UserData> {
1735
2646
  this.guid = preGuid;
1736
2647
  this.cdate = preCdate;
1737
2648
  this.mdate = preMdate;
1738
- this.$nymph = nymph;
2649
+ this.$setNymph(nymph);
1739
2650
  throw new Error("Couldn't send verification email.");
1740
2651
  }
1741
2652
  }
1742
2653
 
1743
2654
  this.$descendantGroups = undefined;
1744
2655
  this.$gatekeeperCache = undefined;
1745
- await tnymph.commit(transaction);
2656
+ ret = await tnymph.commit(transaction);
2657
+ } else {
2658
+ await tnymph.rollback(transaction);
2659
+ }
2660
+ this.$setNymph(nymph);
2661
+
2662
+ if (ret) {
2663
+ this.$originalEmail = this.$data.email;
2664
+ this.$originalUsername = this.$data.username;
1746
2665
 
1747
- if ((nymph.tilmeld as Tilmeld).User.current(true).$is(this)) {
2666
+ const tilmeld = enforceTilmeld(nymph);
2667
+
2668
+ if (tilmeld.User.current(true).$is(this)) {
1748
2669
  // Update the user in the session cache.
1749
- (nymph.tilmeld as Tilmeld).fillSession(this);
2670
+ await tilmeld.fillSession(this);
1750
2671
  }
1751
- } else {
1752
- await tnymph.rollback(transaction);
1753
2672
  }
1754
- this.$nymph = nymph;
2673
+
1755
2674
  return ret;
1756
2675
  }
1757
2676
 
@@ -1772,53 +2691,114 @@ export default class User extends AbleObject<UserData> {
1772
2691
  }
1773
2692
 
1774
2693
  public async $delete() {
1775
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1776
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
1777
- throw new BadDataError("You don't have the authority to delete users.");
2694
+ const tilmeld = enforceTilmeld(this);
2695
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
2696
+ throw new AccessControlError(
2697
+ "You don't have the authority to delete users.",
2698
+ );
1778
2699
  }
1779
2700
  if (
2701
+ !this.$skipAcWhenDeleting &&
1780
2702
  !tilmeld.gatekeeper('system/admin') &&
1781
- this.$gatekeeper('system/admin')
2703
+ this.$data.abilities?.includes('system/admin')
1782
2704
  ) {
1783
- throw new BadDataError(
1784
- "You don't have the authority to delete system admins."
2705
+ throw new AccessControlError(
2706
+ "You don't have the authority to delete system admins.",
2707
+ );
2708
+ }
2709
+ if (this.$data.username !== this.$originalUsername) {
2710
+ throw new AccessControlError(
2711
+ "You can't delete a user while switching their username.",
1785
2712
  );
1786
2713
  }
2714
+
1787
2715
  if (tilmeld.User.current(true).$is(this)) {
1788
2716
  await this.$logout();
1789
2717
  }
1790
- return await super.$delete();
2718
+
2719
+ if (this.$data.group != null && this.$is(this.$data.group.user)) {
2720
+ const transaction = 'tilmeld-delete-' + this.guid;
2721
+ const nymph = this.$nymph;
2722
+ const tnymph = await nymph.startTransaction(transaction);
2723
+ this.$setNymph(tnymph);
2724
+
2725
+ // Read the skip ac value here, because super.$delete changes it.
2726
+ const $skipAcWhenDeleting = this.$skipAcWhenDeleting;
2727
+
2728
+ // Delete the user.
2729
+ let success = await super.$delete();
2730
+ if (!success) {
2731
+ await tnymph.rollback(transaction);
2732
+ this.$setNymph(nymph);
2733
+ return success;
2734
+ }
2735
+
2736
+ // Delete the generated group.
2737
+ success = $skipAcWhenDeleting
2738
+ ? await this.$data.group.$deleteSkipAC()
2739
+ : await this.$data.group.$delete();
2740
+
2741
+ if (success) {
2742
+ success = await tnymph.commit(transaction);
2743
+ } else {
2744
+ await tnymph.rollback(transaction);
2745
+ }
2746
+ this.$setNymph(nymph);
2747
+ return success;
2748
+ } else {
2749
+ return await super.$delete();
2750
+ }
2751
+ }
2752
+
2753
+ /*
2754
+ * This should *never* be accessible on the client.
2755
+ */
2756
+ async $deleteSkipAC() {
2757
+ this.$skipAcWhenDeleting = true;
2758
+ return await this.$delete();
2759
+ }
2760
+
2761
+ $tilmeldDeleteSkipAC() {
2762
+ if (this.$skipAcWhenDeleting) {
2763
+ this.$skipAcWhenDeleting = false;
2764
+ return true;
2765
+ }
2766
+ return false;
1791
2767
  }
1792
2768
 
1793
2769
  public static on<T extends EventType>(
1794
2770
  event: T,
1795
- callback: T extends 'beforeRegister'
1796
- ? TilmeldBeforeRegisterCallback
1797
- : T extends 'afterRegister'
1798
- ? TilmeldAfterRegisterCallback
1799
- : T extends 'beforeLogin'
1800
- ? TilmeldBeforeLoginCallback
1801
- : T extends 'afterLogin'
1802
- ? TilmeldAfterLoginCallback
1803
- : T extends 'beforeLogout'
1804
- ? TilmeldBeforeLogoutCallback
1805
- : T extends 'afterLogout'
1806
- ? TilmeldAfterLogoutCallback
1807
- : never
2771
+ callback: T extends 'checkUsername'
2772
+ ? TilmeldCheckUsernameCallback
2773
+ : T extends 'beforeRegister'
2774
+ ? TilmeldBeforeRegisterCallback
2775
+ : T extends 'afterRegister'
2776
+ ? TilmeldAfterRegisterCallback
2777
+ : T extends 'beforeLogin'
2778
+ ? TilmeldBeforeLoginCallback
2779
+ : T extends 'afterLogin'
2780
+ ? TilmeldAfterLoginCallback
2781
+ : T extends 'beforeLogout'
2782
+ ? TilmeldBeforeLogoutCallback
2783
+ : T extends 'afterLogout'
2784
+ ? TilmeldAfterLogoutCallback
2785
+ : never,
1808
2786
  ) {
1809
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
1810
- ? 'beforeRegisterCallbacks'
1811
- : T extends 'afterRegister'
1812
- ? 'afterRegisterCallbacks'
1813
- : T extends 'beforeLogin'
1814
- ? 'beforeLoginCallbacks'
1815
- : T extends 'afterLogin'
1816
- ? 'afterLoginCallbacks'
1817
- : T extends 'beforeLogout'
1818
- ? 'beforeLogoutCallbacks'
1819
- : T extends 'afterLogout'
1820
- ? 'afterLogoutCallbacks'
1821
- : never;
2787
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2788
+ ? 'checkUsernameCallbacks'
2789
+ : T extends 'beforeRegister'
2790
+ ? 'beforeRegisterCallbacks'
2791
+ : T extends 'afterRegister'
2792
+ ? 'afterRegisterCallbacks'
2793
+ : T extends 'beforeLogin'
2794
+ ? 'beforeLoginCallbacks'
2795
+ : T extends 'afterLogin'
2796
+ ? 'afterLoginCallbacks'
2797
+ : T extends 'beforeLogout'
2798
+ ? 'beforeLogoutCallbacks'
2799
+ : T extends 'afterLogout'
2800
+ ? 'afterLogoutCallbacks'
2801
+ : never;
1822
2802
  if (!(prop in this)) {
1823
2803
  throw new Error('Invalid event type.');
1824
2804
  }
@@ -1829,33 +2809,37 @@ export default class User extends AbleObject<UserData> {
1829
2809
 
1830
2810
  public static off<T extends EventType>(
1831
2811
  event: T,
1832
- callback: T extends 'beforeRegister'
1833
- ? TilmeldBeforeRegisterCallback
1834
- : T extends 'afterRegister'
1835
- ? TilmeldAfterRegisterCallback
1836
- : T extends 'beforeLogin'
1837
- ? TilmeldBeforeLoginCallback
1838
- : T extends 'afterLogin'
1839
- ? TilmeldAfterLoginCallback
1840
- : T extends 'beforeLogout'
1841
- ? TilmeldBeforeLogoutCallback
1842
- : T extends 'afterLogout'
1843
- ? TilmeldAfterLogoutCallback
1844
- : never
2812
+ callback: T extends 'checkUsername'
2813
+ ? TilmeldCheckUsernameCallback
2814
+ : T extends 'beforeRegister'
2815
+ ? TilmeldBeforeRegisterCallback
2816
+ : T extends 'afterRegister'
2817
+ ? TilmeldAfterRegisterCallback
2818
+ : T extends 'beforeLogin'
2819
+ ? TilmeldBeforeLoginCallback
2820
+ : T extends 'afterLogin'
2821
+ ? TilmeldAfterLoginCallback
2822
+ : T extends 'beforeLogout'
2823
+ ? TilmeldBeforeLogoutCallback
2824
+ : T extends 'afterLogout'
2825
+ ? TilmeldAfterLogoutCallback
2826
+ : never,
1845
2827
  ) {
1846
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
1847
- ? 'beforeRegisterCallbacks'
1848
- : T extends 'afterRegister'
1849
- ? 'afterRegisterCallbacks'
1850
- : T extends 'beforeLogin'
1851
- ? 'beforeLoginCallbacks'
1852
- : T extends 'afterLogin'
1853
- ? 'afterLoginCallbacks'
1854
- : T extends 'beforeLogout'
1855
- ? 'beforeLogoutCallbacks'
1856
- : T extends 'afterLogout'
1857
- ? 'afterLogoutCallbacks'
1858
- : never;
2828
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2829
+ ? 'checkUsernameCallbacks'
2830
+ : T extends 'beforeRegister'
2831
+ ? 'beforeRegisterCallbacks'
2832
+ : T extends 'afterRegister'
2833
+ ? 'afterRegisterCallbacks'
2834
+ : T extends 'beforeLogin'
2835
+ ? 'beforeLoginCallbacks'
2836
+ : T extends 'afterLogin'
2837
+ ? 'afterLoginCallbacks'
2838
+ : T extends 'beforeLogout'
2839
+ ? 'beforeLogoutCallbacks'
2840
+ : T extends 'afterLogout'
2841
+ ? 'afterLogoutCallbacks'
2842
+ : never;
1859
2843
  if (!(prop in this)) {
1860
2844
  return false;
1861
2845
  }