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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +414 -0
  2. package/README.md +15 -2
  3. package/dist/AbleObject.d.ts +15 -0
  4. package/dist/AbleObject.js +18 -6
  5. package/dist/AbleObject.js.map +1 -1
  6. package/dist/Group.d.ts +181 -15
  7. package/dist/Group.js +551 -192
  8. package/dist/Group.js.map +1 -1
  9. package/dist/Group.test.js +83 -13
  10. package/dist/Group.test.js.map +1 -1
  11. package/dist/Tilmeld.d.ts +241 -12
  12. package/dist/Tilmeld.js +636 -254
  13. package/dist/Tilmeld.js.map +1 -1
  14. package/dist/Tilmeld.types.d.ts +2 -2
  15. package/dist/Tilmeld.types.js +1 -2
  16. package/dist/User.d.ts +395 -22
  17. package/dist/User.js +1316 -365
  18. package/dist/User.js.map +1 -1
  19. package/dist/User.test.js +132 -13
  20. package/dist/User.test.js.map +1 -1
  21. package/dist/conf/d.d.ts +287 -7
  22. package/dist/conf/d.js +1 -2
  23. package/dist/conf/defaults.d.ts +1 -1
  24. package/dist/conf/defaults.js +188 -141
  25. package/dist/conf/defaults.js.map +1 -1
  26. package/dist/conf/index.d.ts +2 -2
  27. package/dist/conf/index.js +2 -8
  28. package/dist/conf/index.js.map +1 -1
  29. package/dist/enforceTilmeld.d.ts +3 -0
  30. package/dist/enforceTilmeld.js +18 -0
  31. package/dist/enforceTilmeld.js.map +1 -0
  32. package/dist/errors/AccessControlError.d.ts +2 -1
  33. package/dist/errors/AccessControlError.js +3 -6
  34. package/dist/errors/AccessControlError.js.map +1 -1
  35. package/dist/errors/BadDataError.d.ts +2 -1
  36. package/dist/errors/BadDataError.js +3 -6
  37. package/dist/errors/BadDataError.js.map +1 -1
  38. package/dist/errors/BadEmailError.d.ts +2 -1
  39. package/dist/errors/BadEmailError.js +3 -6
  40. package/dist/errors/BadEmailError.js.map +1 -1
  41. package/dist/errors/BadUsernameError.d.ts +2 -1
  42. package/dist/errors/BadUsernameError.js +3 -6
  43. package/dist/errors/BadUsernameError.js.map +1 -1
  44. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js +1 -5
  45. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js.map +1 -1
  46. package/dist/errors/EmailChangeRateLimitExceededError.js +1 -5
  47. package/dist/errors/EmailChangeRateLimitExceededError.js.map +1 -1
  48. package/dist/errors/index.d.ts +6 -6
  49. package/dist/errors/index.js +6 -22
  50. package/dist/errors/index.js.map +1 -1
  51. package/dist/index.d.ts +11 -10
  52. package/dist/index.js +16 -35
  53. package/dist/index.js.map +1 -1
  54. package/dist/tilmeldJoiProps.js +13 -18
  55. package/dist/tilmeldJoiProps.js.map +1 -1
  56. package/jest.config.js +11 -2
  57. package/package.json +34 -31
  58. package/src/AbleObject.ts +2 -2
  59. package/src/Group.test.ts +104 -6
  60. package/src/Group.ts +661 -226
  61. package/src/Tilmeld.ts +420 -254
  62. package/src/Tilmeld.types.ts +2 -2
  63. package/src/User.test.ts +163 -6
  64. package/src/User.ts +1557 -454
  65. package/src/conf/d.ts +63 -17
  66. package/src/conf/defaults.ts +162 -109
  67. package/src/conf/index.ts +2 -2
  68. package/src/enforceTilmeld.ts +20 -0
  69. package/src/errors/AccessControlError.ts +4 -2
  70. package/src/errors/BadDataError.ts +4 -2
  71. package/src/errors/BadEmailError.ts +4 -2
  72. package/src/errors/BadUsernameError.ts +4 -2
  73. package/src/errors/index.ts +6 -6
  74. package/src/index.ts +12 -10
  75. package/src/tilmeldJoiProps.ts +11 -11
  76. package/src/type/locutus.d.ts +3 -0
  77. package/tsconfig.json +5 -3
  78. package/typedoc.json +4 -0
package/src/User.ts CHANGED
@@ -1,49 +1,87 @@
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'
34
+ | 'beforeSave'
35
+ | 'afterSave'
29
36
  | 'beforeLogin'
30
37
  | 'afterLogin'
31
38
  | 'beforeLogout'
32
- | 'afterLogout';
39
+ | 'afterLogout'
40
+ | 'beforeDelete'
41
+ | 'afterDelete';
42
+ /**
43
+ * This is run when the user has entered an otherwise valid username into the
44
+ * signup form. It should return a result, which when false will stop the
45
+ * process and return the included message, disallowing the username.
46
+ */
47
+ export type TilmeldCheckUsernameCallback = (
48
+ user: User & UserData,
49
+ data: { username: string },
50
+ ) => Promise<{ result: boolean; message?: string }>;
33
51
  /**
34
- * Theses are run before the user data checks, so the only checks before are
52
+ * This is run before the user data checks, so the only checks before are
35
53
  * whether registration is allowed and whether the user is already registered.
36
54
  */
37
55
  export type TilmeldBeforeRegisterCallback = (
38
56
  user: User & UserData,
39
- data: { password: string; additionalData?: { [k: string]: any } }
57
+ data: { password: string; additionalData?: { [k: string]: any } },
40
58
  ) => Promise<void>;
59
+ /**
60
+ * This is run before the transaction is committed, and you can perform
61
+ * additional functions on the transaction, which is available in `user.$nymph`.
62
+ */
41
63
  export type TilmeldAfterRegisterCallback = (
42
64
  user: User & UserData,
43
- result: { loggedin: boolean; message: string }
65
+ result: { loggedin: boolean; message: string },
66
+ ) => Promise<void>;
67
+ /**
68
+ * This is run just before the user is saved, so all checks have passed.
69
+ *
70
+ * The transaction is available in `user.$nymph`.
71
+ */
72
+ export type TilmeldBeforeSaveCallback = (
73
+ user: User & UserData,
44
74
  ) => Promise<void>;
45
75
  /**
46
- * These are run after the authentication checks, but before the login action.
76
+ * This is run before the transaction is committed, and you can perform
77
+ * additional functions on the transaction, which is available in `user.$nymph`.
78
+ *
79
+ * This is run after a new email verification email has been sent, so throwing
80
+ * an error will cause confusion for the user if they've changed their email.
81
+ */
82
+ export type TilmeldAfterSaveCallback = (user: User & UserData) => Promise<void>;
83
+ /**
84
+ * This is run after the authentication checks, but before the login action.
47
85
  */
48
86
  export type TilmeldBeforeLoginCallback = (
49
87
  user: User & UserData,
@@ -51,20 +89,32 @@ export type TilmeldBeforeLoginCallback = (
51
89
  username: string;
52
90
  password: string;
53
91
  additionalData?: { [k: string]: any };
54
- }
92
+ },
55
93
  ) => Promise<void>;
56
- /**
57
- * This is run before the transaction is committed, and you can perform
58
- * additional functions on the transaction, which is available in `user.$nymph`.
59
- */
60
94
  export type TilmeldAfterLoginCallback = (
61
- user: User & UserData
95
+ user: User & UserData,
62
96
  ) => Promise<void>;
63
97
  export type TilmeldBeforeLogoutCallback = (
64
- user: User & UserData
98
+ user: User & UserData,
65
99
  ) => Promise<void>;
66
100
  export type TilmeldAfterLogoutCallback = (
67
- user: User & UserData
101
+ user: User & UserData,
102
+ ) => Promise<void>;
103
+ /**
104
+ * This is run before the user is logged out (if the current user is being
105
+ * deleted).
106
+ *
107
+ * The transaction is available in `user.$nymph`.
108
+ */
109
+ export type TilmeldBeforeDeleteCallback = (
110
+ user: User & UserData,
111
+ ) => Promise<void>;
112
+ /**
113
+ * This is run before the transaction is committed, and you can perform
114
+ * additional functions on the transaction, which is available in `user.$nymph`.
115
+ */
116
+ export type TilmeldAfterDeleteCallback = (
117
+ user: User & UserData,
68
118
  ) => Promise<void>;
69
119
 
70
120
  export type UserData = {
@@ -108,11 +158,11 @@ export type UserData = {
108
158
  /**
109
159
  * The user's primary group.
110
160
  */
111
- group?: Group & GroupData;
161
+ group?: GroupClass & GroupData;
112
162
  /**
113
163
  * The user's secondary groups.
114
164
  */
115
- groups?: (Group & GroupData)[];
165
+ groups?: (GroupClass & GroupData)[];
116
166
  /**
117
167
  * Whether the user should inherit the abilities of his groups.
118
168
  */
@@ -135,7 +185,8 @@ export type UserData = {
135
185
  */
136
186
  newEmailSecret?: string;
137
187
  /**
138
- * The new email address.
188
+ * If the user has changed their email address, this is the new one, awaiting
189
+ * verification.
139
190
  */
140
191
  newEmailAddress?: string;
141
192
  /**
@@ -167,6 +218,15 @@ export type UserData = {
167
218
  * database.
168
219
  */
169
220
  passwordTemp?: string;
221
+ /**
222
+ * If set, this timestamp is the cutoff point for JWT issue dates. Any token
223
+ * issued before this date will not authenticate the user.
224
+ */
225
+ revokeTokenDate?: number;
226
+ /**
227
+ * Two factor auth secret.
228
+ */
229
+ totpSecret?: string;
170
230
  };
171
231
 
172
232
  /**
@@ -182,15 +242,19 @@ export default class User extends AbleObject<UserData> {
182
242
  /**
183
243
  * The instance of Tilmeld to use for queries.
184
244
  */
185
- static tilmeld: Tilmeld;
186
245
  static ETYPE = 'tilmeld_user';
187
246
  static class = 'User';
247
+ private static checkUsernameCallbacks: TilmeldCheckUsernameCallback[] = [];
188
248
  private static beforeRegisterCallbacks: TilmeldBeforeRegisterCallback[] = [];
189
249
  private static afterRegisterCallbacks: TilmeldAfterRegisterCallback[] = [];
250
+ private static beforeSaveCallbacks: TilmeldBeforeSaveCallback[] = [];
251
+ private static afterSaveCallbacks: TilmeldAfterSaveCallback[] = [];
190
252
  private static beforeLoginCallbacks: TilmeldBeforeLoginCallback[] = [];
191
253
  private static afterLoginCallbacks: TilmeldAfterLoginCallback[] = [];
192
254
  private static beforeLogoutCallbacks: TilmeldBeforeLogoutCallback[] = [];
193
255
  private static afterLogoutCallbacks: TilmeldAfterLogoutCallback[] = [];
256
+ private static beforeDeleteCallbacks: TilmeldBeforeDeleteCallback[] = [];
257
+ private static afterDeleteCallbacks: TilmeldAfterDeleteCallback[] = [];
194
258
 
195
259
  private static DEFAULT_CLIENT_ENABLED_METHODS = [
196
260
  '$checkUsername',
@@ -200,23 +264,33 @@ export default class User extends AbleObject<UserData> {
200
264
  '$register',
201
265
  ];
202
266
  private static DEFAULT_PRIVATE_DATA = [
267
+ // The things that are commented are there to show what is _not_ private.
268
+ 'abilities',
269
+ // 'username',
270
+ // 'nameFirst',
271
+ // 'nameMiddle',
272
+ // 'nameLast',
273
+ // 'name',
203
274
  'email',
204
- 'originalEmail',
275
+ // 'avatar',
205
276
  'phone',
206
277
  'group',
207
278
  'groups',
208
- 'abilities',
209
279
  'inheritAbilities',
210
- 'recoverSecret',
211
- 'recoverSecretDate',
212
- 'password',
213
- 'salt',
280
+ 'enabled',
214
281
  'secret',
215
- 'newEmailAddress',
282
+ 'emailChangeDate',
216
283
  'newEmailSecret',
217
- 'cancelEmailAddress',
284
+ 'newEmailAddress',
218
285
  'cancelEmailSecret',
219
- 'emailChangeDate',
286
+ 'cancelEmailAddress',
287
+ 'recoverSecret',
288
+ 'recoverSecretDate',
289
+ 'salt',
290
+ 'password',
291
+ 'passwordTemp',
292
+ 'revokeTokenDate',
293
+ 'totpSecret',
220
294
  ];
221
295
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
222
296
 
@@ -227,6 +301,7 @@ export default class User extends AbleObject<UserData> {
227
301
  'sendRecovery',
228
302
  'recover',
229
303
  'getClientConfig',
304
+ 'getDomainUsers',
230
305
  ];
231
306
  protected $privateData = [...User.DEFAULT_PRIVATE_DATA];
232
307
  public static searchRestrictedData = [...User.DEFAULT_PRIVATE_DATA];
@@ -241,13 +316,17 @@ export default class User extends AbleObject<UserData> {
241
316
  */
242
317
  private $gatekeeperCache?: { [k: string]: true };
243
318
  /**
244
- * This is explicitly used only during the registration proccess.
319
+ * This should only be used by the backend.
245
320
  */
246
321
  private $skipAcWhenSaving = false;
322
+ /**
323
+ * This should only be used by the backend.
324
+ */
325
+ private $skipAcWhenDeleting = false;
247
326
  /**
248
327
  * The user's group descendants.
249
328
  */
250
- private $descendantGroups?: (Group & GroupData)[];
329
+ private $descendantGroups?: (GroupClass & GroupData)[];
251
330
  /**
252
331
  * Used to save the current email address to send verification if it changes
253
332
  * from the frontend.
@@ -256,10 +335,10 @@ export default class User extends AbleObject<UserData> {
256
335
  * verification, don't set this.
257
336
  */
258
337
  public $originalEmail?: string;
259
-
260
- static async factory(guid?: string): Promise<User & UserData> {
261
- return (await super.factory(guid)) as User & UserData;
262
- }
338
+ /**
339
+ * Used to save the current username for domain admin permissions.
340
+ */
341
+ public $originalUsername?: string;
263
342
 
264
343
  static async factoryUsername(username?: string): Promise<User & UserData> {
265
344
  const entity = new this();
@@ -271,7 +350,7 @@ export default class User extends AbleObject<UserData> {
271
350
  {
272
351
  type: '&',
273
352
  ilike: ['username', username.replace(/([\\%_])/g, (s) => `\\${s}`)],
274
- }
353
+ },
275
354
  );
276
355
  if (entity != null) {
277
356
  return entity;
@@ -280,34 +359,18 @@ export default class User extends AbleObject<UserData> {
280
359
  return entity;
281
360
  }
282
361
 
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
362
  public static current(returnObjectIfNotExist: true): User & UserData;
301
363
  public static current(
302
- returnObjectIfNotExist?: false
364
+ returnObjectIfNotExist?: false,
303
365
  ): (User & UserData) | null;
304
366
  public static current(
305
- returnObjectIfNotExist?: boolean
367
+ returnObjectIfNotExist?: boolean,
306
368
  ): (User & UserData) | null {
307
- if (this.tilmeld.currentUser == null) {
369
+ const tilmeld = enforceTilmeld(this);
370
+ if (tilmeld.currentUser == null) {
308
371
  return returnObjectIfNotExist ? this.factorySync() : null;
309
372
  }
310
- return this.tilmeld.currentUser;
373
+ return tilmeld.currentUser;
311
374
  }
312
375
 
313
376
  /**
@@ -320,7 +383,8 @@ export default class User extends AbleObject<UserData> {
320
383
  recoveryType: 'username' | 'password';
321
384
  account: string;
322
385
  }): Promise<{ result: boolean; message: string }> {
323
- if (!this.tilmeld.config.pwRecovery) {
386
+ const tilmeld = enforceTilmeld(this);
387
+ if (!tilmeld.config.pwRecovery) {
324
388
  return {
325
389
  result: false,
326
390
  message: 'Account recovery is not allowed.',
@@ -330,18 +394,15 @@ export default class User extends AbleObject<UserData> {
330
394
  let user: User & UserData;
331
395
  const options: EmailOptions = {};
332
396
 
333
- if (
334
- !this.tilmeld.config.emailUsernames &&
335
- data.recoveryType === 'username'
336
- ) {
397
+ if (!tilmeld.config.emailUsernames && data.recoveryType === 'username') {
337
398
  // Create a username recovery email.
338
399
 
339
400
  const getUser = await this.nymph.getEntity(
340
- { class: this.tilmeld.User, skipAc: true },
401
+ { class: tilmeld.User, skipAc: true },
341
402
  {
342
403
  type: '&',
343
404
  ilike: ['email', data.account.replace(/([\\%_])/g, (s) => `\\${s}`)],
344
- }
405
+ },
345
406
  );
346
407
 
347
408
  if (getUser == null) {
@@ -364,7 +425,7 @@ export default class User extends AbleObject<UserData> {
364
425
  } else if (data.recoveryType === 'password') {
365
426
  // Create a password recovery email.
366
427
 
367
- const getUser = await this.tilmeld.User.factoryUsername(data.account);
428
+ const getUser = await tilmeld.User.factoryUsername(data.account);
368
429
 
369
430
  if (getUser.guid == null) {
370
431
  return {
@@ -391,14 +452,19 @@ export default class User extends AbleObject<UserData> {
391
452
  };
392
453
  options.locals = {
393
454
  recoverCode: getUser.recoverSecret,
394
- timeLimit: this.tilmeld.config.pwRecoveryTimeLimit,
455
+ timeLimit: tilmeld.config.pwRecoveryTimeLimit,
395
456
  };
396
457
  } else {
397
458
  return { result: false, message: 'Invalid recovery type.' };
398
459
  }
399
460
 
461
+ this.nymph.config.debugLog(
462
+ 'tilmeld',
463
+ `User account recovery (${options.template}) request for "${user.username}".`,
464
+ );
465
+
400
466
  // Send the email.
401
- if (await this.tilmeld.config.sendEmail(this.tilmeld, options, user)) {
467
+ if (await tilmeld.config.sendEmail(tilmeld, options, user)) {
402
468
  return {
403
469
  result: true,
404
470
  message:
@@ -421,22 +487,23 @@ export default class User extends AbleObject<UserData> {
421
487
  secret: string;
422
488
  password: string;
423
489
  }): Promise<{ result: boolean; message: string }> {
424
- if (!this.tilmeld.config.pwRecovery) {
490
+ const tilmeld = enforceTilmeld(this);
491
+ if (!tilmeld.config.pwRecovery) {
425
492
  return {
426
493
  result: false,
427
494
  message: 'Account recovery is not allowed.',
428
495
  };
429
496
  }
430
497
 
431
- const user = await this.tilmeld.User.factoryUsername(data.username);
498
+ const user = await tilmeld.User.factoryUsername(data.username);
432
499
 
433
500
  if (
434
501
  user.guid == null ||
435
502
  user.recoverSecret == null ||
436
503
  data.secret !== user.recoverSecret ||
437
504
  strtotime(
438
- '+' + this.tilmeld.config.pwRecoveryTimeLimit,
439
- Math.floor((user.recoverSecretDate ?? 0) / 1000)
505
+ '+' + tilmeld.config.pwRecoveryTimeLimit,
506
+ Math.floor((user.recoverSecretDate ?? 0) / 1000),
440
507
  ) *
441
508
  1000 <
442
509
  Date.now()
@@ -451,6 +518,11 @@ export default class User extends AbleObject<UserData> {
451
518
  return { result: false, message: 'Password cannot be empty.' };
452
519
  }
453
520
 
521
+ this.nymph.config.debugLog(
522
+ 'tilmeld',
523
+ `User account recovery for "${user.username}".`,
524
+ );
525
+
454
526
  user.$password(data.password);
455
527
  delete user.recoverSecret;
456
528
  delete user.recoverSecretDate;
@@ -466,27 +538,31 @@ export default class User extends AbleObject<UserData> {
466
538
  }
467
539
 
468
540
  public static getClientConfig() {
541
+ const tilmeld = enforceTilmeld(this);
469
542
  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,
543
+ regFields: tilmeld.config.regFields,
544
+ userFields: tilmeld.config.userFields,
545
+ emailUsernames: tilmeld.config.emailUsernames,
546
+ domainSupport: tilmeld.config.domainSupport,
547
+ allowRegistration: tilmeld.config.allowRegistration,
548
+ allowUsernameChange: tilmeld.config.allowUsernameChange,
549
+ pwRecovery: tilmeld.config.pwRecovery,
550
+ verifyEmail: tilmeld.config.verifyEmail,
551
+ unverifiedAccess: tilmeld.config.unverifiedAccess,
478
552
  };
479
553
  }
480
554
 
481
555
  public static async loginUser(data: {
482
556
  username: string;
483
557
  password: string;
558
+ code?: string;
484
559
  additionalData?: { [k: string]: any };
485
560
  }) {
561
+ const tilmeld = enforceTilmeld(this);
486
562
  if (!('username' in data) || !data.username.length) {
487
563
  return { result: false, message: 'Incorrect login/password.' };
488
564
  }
489
- const user = await this.tilmeld.User.factoryUsername(data.username);
565
+ const user = await tilmeld.User.factoryUsername(data.username);
490
566
  const result: { result: boolean; message: string; user?: User & UserData } =
491
567
  await user.$login(data);
492
568
  if (result.result) {
@@ -496,25 +572,149 @@ export default class User extends AbleObject<UserData> {
496
572
  return result;
497
573
  }
498
574
 
575
+ /**
576
+ * Get the users in a domain. This is only accessible to the domain's admins.
577
+ */
578
+ public static async getDomainUsers(
579
+ domain: string,
580
+ options?: {
581
+ limit?: number;
582
+ offset?: number;
583
+ sort?: string;
584
+ reverse?: boolean;
585
+ },
586
+ ) {
587
+ const tilmeld = enforceTilmeld(this);
588
+
589
+ if (!tilmeld.config.domainSupport) {
590
+ throw new AccessControlError('Domain support is not enabled.');
591
+ }
592
+
593
+ if (!domain) {
594
+ throw new BadDataError('Domain is required.');
595
+ }
596
+
597
+ domain = domain.toLowerCase();
598
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
599
+ throw new BadDataError(tilmeld.config.validDomainRegexNotice);
600
+ }
601
+
602
+ if (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
603
+ throw new AccessControlError(
604
+ 'You do not have the authority to manage this domain.',
605
+ );
606
+ }
607
+
608
+ return await this.nymph.getEntities(
609
+ {
610
+ class: this.nymph.getEntityClass(User),
611
+ ...(options?.limit != null
612
+ ? {
613
+ limit: options.limit,
614
+ }
615
+ : {}),
616
+ ...(options?.offset != null
617
+ ? {
618
+ offset: options.offset,
619
+ }
620
+ : {}),
621
+ ...(options?.sort != null
622
+ ? {
623
+ sort: options.sort,
624
+ }
625
+ : {}),
626
+ ...(options?.reverse != null
627
+ ? {
628
+ reverse: options.reverse,
629
+ }
630
+ : {}),
631
+ },
632
+ {
633
+ type: '&',
634
+ like: ['username', `%@${domain.replace(/([\\%_])/g, (s) => `\\${s}`)}`],
635
+ },
636
+ );
637
+ }
638
+
639
+ constructor() {
640
+ super();
641
+
642
+ // Defaults.
643
+ this.$data.enabled = true;
644
+ this.$data.abilities = [];
645
+ this.$data.groups = [];
646
+ this.$data.inheritAbilities = true;
647
+ this.$updateDataProtection();
648
+ }
649
+
650
+ $setNymph(nymph: Nymph) {
651
+ this.$nymph = nymph;
652
+ if (!this.$asleep()) {
653
+ if (this.$data.group && this.$data.group.$nymph !== nymph) {
654
+ this.$data.group.$setNymph(nymph);
655
+ }
656
+ for (const group of this.$data.groups ?? []) {
657
+ if (group && group.$nymph !== nymph) {
658
+ group.$setNymph(nymph);
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ async $getUniques(): Promise<string[]> {
665
+ const tilmeld = enforceTilmeld(this);
666
+ const uniques = [`u:${this.$data.username}`];
667
+ if (
668
+ !tilmeld.config.emailUsernames &&
669
+ tilmeld.config.userFields.includes('email')
670
+ ) {
671
+ uniques.push(`e:${this.$data.email}`);
672
+
673
+ if (this.$data.newEmailAddress != null) {
674
+ uniques.push(`e:${this.$data.newEmailAddress}`);
675
+ }
676
+ }
677
+ return uniques;
678
+ }
679
+
499
680
  public async $login(data: {
500
681
  username: string;
501
682
  password: string;
683
+ code?: string;
502
684
  additionalData?: { [k: string]: any };
503
685
  }) {
504
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
686
+ const tilmeld = enforceTilmeld(this);
505
687
  if (this.guid == null) {
506
688
  return { result: false, message: 'Incorrect login/password.' };
507
689
  }
508
690
  if (!this.$data.enabled) {
509
691
  return { result: false, message: 'This user is disabled.' };
510
692
  }
511
- if (this.$gatekeeper()) {
693
+ if (await this.$gatekeeper()) {
512
694
  return { result: true, message: 'You are already logged in.' };
513
695
  }
514
696
  if (!this.$checkPassword(data.password)) {
515
697
  return { result: false, message: 'Incorrect login/password.' };
516
698
  }
517
699
 
700
+ if (this.$data.totpSecret != null) {
701
+ if (
702
+ data.code == null ||
703
+ typeof data.code !== 'string' ||
704
+ data.code.length !== 6
705
+ ) {
706
+ return {
707
+ result: false,
708
+ needTOTP: true,
709
+ message: 'You need to provide a 2FA code.',
710
+ };
711
+ }
712
+
713
+ if (!this.$checkTOTPCode(data.code)) {
714
+ return { result: false, message: 'Incorrect 2FA code.' };
715
+ }
716
+ }
717
+
518
718
  try {
519
719
  for (let callback of (this.constructor as typeof User)
520
720
  .beforeLoginCallbacks) {
@@ -529,8 +729,90 @@ export default class User extends AbleObject<UserData> {
529
729
  };
530
730
  }
531
731
 
732
+ this.$nymph.config.debugLog(
733
+ 'tilmeld',
734
+ `User login for "${this.$data.username}".`,
735
+ );
736
+
737
+ // Authentication was successful, attempt to login.
738
+ if (!(await tilmeld.login(this, true))) {
739
+ return { result: false, message: 'Incorrect login/password.' };
740
+ }
741
+
742
+ for (let callback of (this.constructor as typeof User)
743
+ .afterLoginCallbacks) {
744
+ if (callback) {
745
+ await callback(this);
746
+ }
747
+ }
748
+
749
+ // Login was successful.
750
+ return { result: true, message: 'You are logged in.' };
751
+ }
752
+
753
+ public async $switchUser(data?: { additionalData?: { [k: string]: any } }) {
754
+ const tilmeld = enforceTilmeld(this);
755
+ if (this.guid == null) {
756
+ return { result: false, message: 'Incorrect login/password.' };
757
+ }
758
+
759
+ let user = tilmeld.User.current();
760
+
761
+ if (!user || user.guid == null) {
762
+ return { result: false, message: "You're not logged in." };
763
+ }
764
+
765
+ const isCurrentUser = user != null && user.$is(this);
766
+
767
+ if (isCurrentUser) {
768
+ return { result: false, message: "You can't switch to yourself." };
769
+ }
770
+
771
+ const toAbilities = this.$data.abilities || [];
772
+ const fromAbilities = user.abilities || [];
773
+
774
+ if (
775
+ !fromAbilities.includes('tilmeld/switch') &&
776
+ !fromAbilities.includes('system/admin')
777
+ ) {
778
+ return {
779
+ result: false,
780
+ message: "You don't have permission to switch users.",
781
+ };
782
+ }
783
+
784
+ if (
785
+ toAbilities.includes('system/admin') ||
786
+ toAbilities.includes('tilmeld/admin')
787
+ ) {
788
+ return { result: false, message: "You can't switch to an admin." };
789
+ }
790
+
791
+ try {
792
+ for (let callback of (this.constructor as typeof User)
793
+ .beforeLoginCallbacks) {
794
+ if (callback) {
795
+ await callback(this, {
796
+ username: this.$data.username || '',
797
+ password: '',
798
+ ...(data || {}),
799
+ });
800
+ }
801
+ }
802
+ } catch (e: any) {
803
+ return {
804
+ result: false,
805
+ message: e.message,
806
+ };
807
+ }
808
+
809
+ this.$nymph.config.debugLog(
810
+ 'tilmeld',
811
+ `User login switch for "${user.username}" to "${this.$data.username}".`,
812
+ );
813
+
532
814
  // Authentication was successful, attempt to login.
533
- if (!tilmeld.login(this, true)) {
815
+ if (!(await tilmeld.loginSwitch(this, true))) {
534
816
  return { result: false, message: 'Incorrect login/password.' };
535
817
  }
536
818
 
@@ -550,7 +832,7 @@ export default class User extends AbleObject<UserData> {
550
832
  * @returns An object with a boolean 'result' entry and a 'message' entry.
551
833
  */
552
834
  public async $logout() {
553
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
835
+ const tilmeld = enforceTilmeld(this);
554
836
 
555
837
  try {
556
838
  for (let callback of (this.constructor as typeof User)
@@ -566,7 +848,12 @@ export default class User extends AbleObject<UserData> {
566
848
  };
567
849
  }
568
850
 
569
- tilmeld.logout();
851
+ this.$nymph.config.debugLog(
852
+ 'tilmeld',
853
+ `User logout for "${this.$data.username}".`,
854
+ );
855
+
856
+ await tilmeld.logout();
570
857
 
571
858
  for (let callback of (this.constructor as typeof User)
572
859
  .afterLogoutCallbacks) {
@@ -587,89 +874,176 @@ export default class User extends AbleObject<UserData> {
587
874
  }
588
875
  return (
589
876
  'https://secure.gravatar.com/avatar/' +
590
- md5(this.$data.email.trim().toLowerCase()).toString() +
877
+ crypto
878
+ .createHash('sha256')
879
+ .update(this.$data.email.trim().toLowerCase())
880
+ .digest('hex')
881
+ .toLowerCase() +
591
882
  '?d=identicon&s=40'
592
883
  );
593
884
  }
594
885
 
886
+ public $getGid() {
887
+ if (this.$data.group) {
888
+ return this.$data.group.guid;
889
+ }
890
+ return null;
891
+ }
892
+
893
+ public $getGids() {
894
+ if (this.$data.groups) {
895
+ return this.$data.groups
896
+ .map((group) => group.guid)
897
+ .filter((guid) => guid != null) as string[];
898
+ }
899
+ return null;
900
+ }
901
+
595
902
  /**
596
903
  * Get the user's group descendants.
597
904
  */
598
- public async $getDescendantGroups(): Promise<(Group & GroupData)[]> {
905
+ public async $getDescendantGroups(): Promise<(GroupClass & GroupData)[]> {
599
906
  if (this.$descendantGroups == null) {
600
907
  this.$descendantGroups = [];
601
908
  if (this.$data.group != null) {
909
+ await this.$data.group.$wake();
602
910
  this.$descendantGroups = await this.$data.group.$getDescendants();
603
911
  }
912
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
604
913
  for (let curGroup of this.$data.groups ?? []) {
605
914
  this.$descendantGroups = this.$descendantGroups?.concat(
606
- await curGroup.$getDescendants()
915
+ await curGroup.$getDescendants(),
607
916
  );
608
917
  }
609
918
  }
610
919
  return this.$descendantGroups;
611
920
  }
612
921
 
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
- );
922
+ public $jsonAcceptData(input: EntityJson, allowConflict = false) {
923
+ const tilmeld = enforceTilmeld(this);
924
+ this.$check();
925
+
926
+ if ('abilities' in input.data) {
927
+ this.$checkIncomingAbilities(input.data.abilities);
928
+ }
929
+
930
+ // If the user is a new domain user, the data protection needs to be updated
931
+ // before the data is accepted, or else some things won't be accepted
932
+ // correctly (enabled, passwordTemp).
933
+ let reloadDataProtection = false;
934
+ if (tilmeld.config.domainSupport) {
935
+ let [_username, domain] = splitn(input.data.username ?? '', '@', 2);
936
+ // Lowercase the domain part.
937
+ if (domain) {
938
+ domain = domain.toLowerCase();
626
939
  }
940
+ const user = tilmeld.User.current();
941
+ const isCurrentUser = user != null && user.$is(this);
942
+ // While loading, accessing abilities on the current user will infinitely
943
+ // loop, but if this is the current user, we can use its abilities.
944
+ const abilities =
945
+ (isCurrentUser || user == null
946
+ ? this.$data.abilities
947
+ : user?.abilities) ?? [];
948
+ const isNewUser = input.guid == null;
949
+ const isDomainUser =
950
+ !isCurrentUser &&
951
+ user != null &&
952
+ domain &&
953
+ abilities.includes(`tilmeld/domain/${domain}/admin`);
954
+
955
+ reloadDataProtection = !!(isNewUser && isDomainUser);
956
+ }
957
+
958
+ super.$jsonAcceptData(input, allowConflict);
959
+
960
+ if (reloadDataProtection) {
961
+ // Load it again after data protection is run.
962
+ this.$updateDataProtection();
963
+ super.$jsonAcceptData(input, allowConflict);
627
964
  }
628
- return this.$descendantGroups;
629
965
  }
630
966
 
631
- public $jsonAcceptData(input: EntityJson, allowConflict = false) {
632
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
633
- this.$referenceWake();
967
+ public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
968
+ this.$check();
969
+
970
+ if ('abilities' in patch.set) {
971
+ this.$checkIncomingAbilities(patch.set.abilities);
972
+ }
973
+
974
+ super.$jsonAcceptPatch(patch, allowConflict);
975
+ }
976
+
977
+ private $checkIncomingAbilities(incoming: any) {
978
+ const tilmeld = enforceTilmeld(this);
634
979
 
635
980
  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')
981
+ !Array.isArray(incoming) ||
982
+ incoming.find((ability) => typeof ability !== 'string')
983
+ ) {
984
+ throw new BadDataError('Abilities must be an array of strings.');
985
+ }
986
+
987
+ if (
988
+ incoming.includes('system/admin') !==
989
+ this.$data.abilities?.includes('system/admin') &&
990
+ (!tilmeld.currentUser ||
991
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
641
992
  ) {
642
993
  throw new BadDataError(
643
- "You don't have the authority to make this user a system admin."
994
+ "You don't have the authority to grant or revoke system/admin.",
644
995
  );
645
996
  }
646
997
 
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();
998
+ if (
999
+ incoming.includes('tilmeld/admin') !==
1000
+ this.$data.abilities?.includes('tilmeld/admin') &&
1001
+ (!tilmeld.currentUser ||
1002
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
1003
+ ) {
1004
+ throw new BadDataError(
1005
+ "You don't have the authority to grant or revoke tilmeld/admin.",
1006
+ );
1007
+ }
654
1008
 
655
1009
  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')
1010
+ incoming.includes('tilmeld/switch') !==
1011
+ this.$data.abilities?.includes('tilmeld/switch') &&
1012
+ (!tilmeld.currentUser ||
1013
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
661
1014
  ) {
662
1015
  throw new BadDataError(
663
- "You don't have the authority to make this user a system admin."
1016
+ "You don't have the authority to grant or revoke tilmeld/switch.",
664
1017
  );
665
1018
  }
666
1019
 
667
- this.$originalEmail = this.$data.email;
668
- super.$jsonAcceptPatch(patch, allowConflict);
1020
+ if (
1021
+ xor(
1022
+ incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
1023
+ this.$data.abilities?.filter((ability: string) =>
1024
+ ability.startsWith('tilmeld/'),
1025
+ ) ?? [],
1026
+ ).length &&
1027
+ (!tilmeld.currentUser ||
1028
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
1029
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
1030
+ ) {
1031
+ throw new BadDataError(
1032
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
1033
+ );
1034
+ }
669
1035
  }
670
1036
 
671
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
672
- super.$putData(data, sdata);
1037
+ public $putData(
1038
+ data: EntityData,
1039
+ sdata?: SerializedEntityData,
1040
+ source?: 'server',
1041
+ ) {
1042
+ super.$putData(data, sdata, source);
1043
+ if (source === 'server') {
1044
+ this.$originalEmail = this.$data.email;
1045
+ this.$originalUsername = this.$data.username;
1046
+ }
673
1047
  this.$updateDataProtection();
674
1048
  }
675
1049
 
@@ -679,8 +1053,15 @@ export default class User extends AbleObject<UserData> {
679
1053
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
680
1054
  */
681
1055
  public $updateDataProtection(givenUser?: User & UserData) {
682
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1056
+ const tilmeld = enforceTilmeld(this);
683
1057
  let user = givenUser ?? tilmeld.User.current();
1058
+ let [_username, domain] = tilmeld.config.domainSupport
1059
+ ? splitn(this.$data.username ?? '', '@', 2)
1060
+ : [this.$data.username, null];
1061
+ // Lowercase the domain part.
1062
+ if (domain) {
1063
+ domain = domain.toLowerCase();
1064
+ }
684
1065
 
685
1066
  this.$clientEnabledMethods = [...User.DEFAULT_CLIENT_ENABLED_METHODS];
686
1067
  this.$privateData = [...User.DEFAULT_PRIVATE_DATA];
@@ -698,53 +1079,165 @@ export default class User extends AbleObject<UserData> {
698
1079
  ? this.$data.abilities
699
1080
  : user?.abilities) ?? [];
700
1081
  const isNewUser = this.guid == null;
1082
+ const isDomainUser =
1083
+ tilmeld.config.domainSupport &&
1084
+ !isCurrentUser &&
1085
+ user != null &&
1086
+ domain &&
1087
+ abilities.includes(`tilmeld/domain/${domain}/admin`);
701
1088
 
702
1089
  if (isCurrentUser) {
703
1090
  // Users can check to see what abilities they have.
704
1091
  this.$clientEnabledMethods.push('$gatekeeper');
705
1092
  this.$clientEnabledMethods.push('$changePassword');
1093
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
706
1094
  this.$clientEnabledMethods.push('$logout');
707
1095
  this.$clientEnabledMethods.push('$sendEmailVerification');
1096
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1097
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
1098
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
1099
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
1100
+ }
1101
+
1102
+ if (isDomainUser) {
1103
+ // Domain admins can check to see what abilities their users have.
1104
+ this.$clientEnabledMethods.push('$gatekeeper');
1105
+ this.$clientEnabledMethods.push('$changePassword');
1106
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
1107
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1108
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
1109
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
1110
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
708
1111
  }
709
1112
 
710
1113
  if (
711
1114
  user != null &&
712
- (abilities.indexOf('tilmeld/admin') !== -1 ||
713
- abilities.indexOf('system/admin') !== -1)
1115
+ (abilities.includes('tilmeld/admin') ||
1116
+ abilities.includes('system/admin'))
714
1117
  ) {
715
1118
  // Users who can edit other users can see most of their data.
716
- this.$privateData = ['password', 'salt'];
1119
+ this.$privateData = ['password', 'salt', 'totpSecret'];
1120
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1121
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
717
1122
  this.$allowlistData = undefined;
718
- } else if (isCurrentUser || isNewUser) {
719
- // Users can see their own data, and edit some of it.
1123
+ } else if (isDomainUser) {
1124
+ // Domain admins can see their users' data, and edit some of it.
720
1125
  if (tilmeld.config.allowUsernameChange || isNewUser) {
721
1126
  this.$allowlistData.push('username');
722
1127
  }
723
1128
  this.$allowlistData.push('avatar');
724
- if (tilmeld.config.userFields.indexOf('name') !== -1) {
1129
+ if (tilmeld.config.userFields.includes('name')) {
725
1130
  this.$allowlistData.push('nameFirst');
726
1131
  this.$allowlistData.push('nameMiddle');
727
1132
  this.$allowlistData.push('nameLast');
728
1133
  this.$allowlistData.push('name');
729
1134
  }
730
- if (tilmeld.config.userFields.indexOf('email') !== -1) {
1135
+ if (tilmeld.config.userFields.includes('email')) {
731
1136
  this.$allowlistData.push('email');
732
1137
  }
733
- if (tilmeld.config.userFields.indexOf('phone') !== -1) {
1138
+ if (tilmeld.config.userFields.includes('phone')) {
734
1139
  this.$allowlistData.push('phone');
735
1140
  }
1141
+ this.$allowlistData.push('enabled');
1142
+ this.$allowlistData.push('passwordTemp');
736
1143
  this.$privateData = [
1144
+ // The things commented are there to show what is _not_ private.
1145
+ // 'abilities',
1146
+ // 'username',
1147
+ // 'nameFirst',
1148
+ // 'nameMiddle',
1149
+ // 'nameLast',
1150
+ // 'name',
1151
+ // 'email',
1152
+ // 'avatar',
1153
+ // 'phone',
1154
+ // 'group',
1155
+ // 'groups',
1156
+ // 'inheritAbilities',
1157
+ // 'enabled',
737
1158
  'secret',
1159
+ 'emailChangeDate',
738
1160
  'newEmailSecret',
739
- 'cancelEmailAddress',
1161
+ // 'newEmailAddress',
740
1162
  'cancelEmailSecret',
741
- 'emailChangeDate',
1163
+ 'cancelEmailAddress',
742
1164
  'recoverSecret',
743
1165
  'recoverSecretDate',
1166
+ 'salt',
744
1167
  'password',
1168
+ // 'passwordTemp',
1169
+ // 'revokeTokenDate',
1170
+ 'totpSecret',
1171
+ ];
1172
+ } else if (isCurrentUser || isNewUser) {
1173
+ // Users can see their own data, and edit some of it.
1174
+ if (tilmeld.config.allowUsernameChange || isNewUser) {
1175
+ this.$allowlistData.push('username');
1176
+ }
1177
+ this.$allowlistData.push('avatar');
1178
+ if (tilmeld.config.userFields.includes('name')) {
1179
+ this.$allowlistData.push('nameFirst');
1180
+ this.$allowlistData.push('nameMiddle');
1181
+ this.$allowlistData.push('nameLast');
1182
+ this.$allowlistData.push('name');
1183
+ }
1184
+ if (tilmeld.config.userFields.includes('email')) {
1185
+ this.$allowlistData.push('email');
1186
+ }
1187
+ if (tilmeld.config.userFields.includes('phone')) {
1188
+ this.$allowlistData.push('phone');
1189
+ }
1190
+ this.$privateData = [
1191
+ // The things commented are there to show what is _not_ private.
1192
+ // 'abilities',
1193
+ // 'username',
1194
+ // 'nameFirst',
1195
+ // 'nameMiddle',
1196
+ // 'nameLast',
1197
+ // 'name',
1198
+ // 'email',
1199
+ // 'avatar',
1200
+ // 'phone',
1201
+ // 'group',
1202
+ // 'groups',
1203
+ // 'inheritAbilities',
1204
+ 'enabled',
1205
+ 'secret',
1206
+ 'emailChangeDate',
1207
+ 'newEmailSecret',
1208
+ // 'newEmailAddress',
1209
+ 'cancelEmailSecret',
1210
+ 'cancelEmailAddress',
1211
+ 'recoverSecret',
1212
+ 'recoverSecretDate',
745
1213
  'salt',
1214
+ 'password',
1215
+ 'passwordTemp',
1216
+ // 'revokeTokenDate',
1217
+ 'totpSecret',
746
1218
  ];
747
1219
  }
1220
+
1221
+ if (
1222
+ user != null &&
1223
+ !isCurrentUser &&
1224
+ (abilities.includes('tilmeld/switch') ||
1225
+ abilities.includes('system/admin'))
1226
+ ) {
1227
+ // Users with this ability can switch to other users.
1228
+ this.$clientEnabledMethods.push('$switchUser');
1229
+ }
1230
+ }
1231
+
1232
+ public $updateGroupDataProtection() {
1233
+ if (this.$data.group != null && !this.$data.group.$asleep()) {
1234
+ this.$data.group.$updateDataProtection();
1235
+ }
1236
+ for (let group of this.$data.groups ?? []) {
1237
+ if (!group.$asleep()) {
1238
+ group.$updateDataProtection();
1239
+ }
1240
+ }
748
1241
  }
749
1242
 
750
1243
  /**
@@ -761,35 +1254,43 @@ export default class User extends AbleObject<UserData> {
761
1254
  * @param ability The ability.
762
1255
  * @returns True or false.
763
1256
  */
764
- public $gatekeeper(ability?: string): boolean {
765
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1257
+ public async $gatekeeper(ability?: string): Promise<boolean> {
1258
+ await this.$wake();
1259
+ const tilmeld = enforceTilmeld(this);
766
1260
  if (ability == null) {
767
1261
  return tilmeld.User.current()?.$is(this) ?? false;
768
1262
  }
769
1263
  // Check the cache to see if we've already checked this user.
770
1264
  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
- );
1265
+ this.$gatekeeperCache = await this.$getGatekeeperCache();
789
1266
  }
790
1267
  return (
791
1268
  (ability in this.$gatekeeperCache && !!this.$gatekeeperCache[ability]) ||
792
- !!this.$gatekeeperCache['system/admin']
1269
+ !!this.$data.abilities?.includes('system/admin')
1270
+ );
1271
+ }
1272
+
1273
+ /**
1274
+ * Build a gatekeeper cache object.
1275
+ */
1276
+ public async $getGatekeeperCache() {
1277
+ await this.$wake();
1278
+ let abilities = this.$data.abilities ?? [];
1279
+ if (this.$data.inheritAbilities) {
1280
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1281
+ for (let curGroup of this.$data.groups ?? []) {
1282
+ if (curGroup.enabled) {
1283
+ abilities = abilities.concat(curGroup.abilities ?? []);
1284
+ }
1285
+ }
1286
+ await this.$data.group?.$wake();
1287
+ const group = this.$data.group;
1288
+ if (group != null && group.cdate != null && group.enabled) {
1289
+ abilities = abilities.concat(group.abilities ?? []);
1290
+ }
1291
+ }
1292
+ return Object.fromEntries(
1293
+ abilities.map((ability) => [ability, true as true]),
793
1294
  );
794
1295
  }
795
1296
 
@@ -806,7 +1307,7 @@ export default class User extends AbleObject<UserData> {
806
1307
  * @returns True on success, false on failure.
807
1308
  */
808
1309
  public async $sendEmailVerification() {
809
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1310
+ const tilmeld = enforceTilmeld(this);
810
1311
  if (this.guid == null) {
811
1312
  return false;
812
1313
  }
@@ -818,7 +1319,7 @@ export default class User extends AbleObject<UserData> {
818
1319
 
819
1320
  if (this.$data.secret != null) {
820
1321
  const link = `${verifyUrl}?action=verify&id=${encodeURIComponent(
821
- this.guid
1322
+ this.guid,
822
1323
  )}&secret=${encodeURIComponent(this.$data.secret)}`;
823
1324
  success =
824
1325
  success &&
@@ -836,13 +1337,13 @@ export default class User extends AbleObject<UserData> {
836
1337
  verifyLink: link,
837
1338
  },
838
1339
  },
839
- this
1340
+ this,
840
1341
  ));
841
1342
  }
842
1343
 
843
1344
  if (this.$data.newEmailSecret != null) {
844
1345
  const link = `${verifyUrl}?action=verifychange&id=${encodeURIComponent(
845
- this.guid
1346
+ this.guid,
846
1347
  )}&secret=${encodeURIComponent(this.$data.newEmailSecret)}`;
847
1348
  success =
848
1349
  success &&
@@ -862,13 +1363,13 @@ export default class User extends AbleObject<UserData> {
862
1363
  newEmail: this.$data.newEmailAddress,
863
1364
  },
864
1365
  },
865
- this
1366
+ this,
866
1367
  ));
867
1368
  }
868
1369
 
869
1370
  if (this.$data.cancelEmailSecret != null) {
870
1371
  const link = `${verifyUrl}?action=cancelchange&id=${encodeURIComponent(
871
- this.guid
1372
+ this.guid,
872
1373
  )}&secret=${encodeURIComponent(this.$data.cancelEmailSecret)}`;
873
1374
  success =
874
1375
  success &&
@@ -888,7 +1389,7 @@ export default class User extends AbleObject<UserData> {
888
1389
  newEmail: this.$data.newEmailAddress ?? this.$data.email,
889
1390
  },
890
1391
  },
891
- this
1392
+ this,
892
1393
  ));
893
1394
  }
894
1395
 
@@ -901,13 +1402,16 @@ export default class User extends AbleObject<UserData> {
901
1402
  * @param group The group.
902
1403
  * @returns True if the user is already in the group. The resulting array of groups if the user was not.
903
1404
  */
904
- public $addGroup(group: Group & GroupData) {
905
- if (this.$data.groups == null) {
906
- this.$data.groups = [];
1405
+ public async $addGroup(group: GroupClass & GroupData) {
1406
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1407
+ let groups = this.$data.groups;
1408
+ if (groups == null) {
1409
+ groups = [];
907
1410
  }
908
- if (!group.$inArray(this.$data.groups)) {
909
- this.$data.groups.push(group);
910
- return this.$data.groups;
1411
+ if (!group.$inArray(groups)) {
1412
+ groups.push(group);
1413
+ this.$data.groups = groups;
1414
+ return groups;
911
1415
  }
912
1416
  return true;
913
1417
  }
@@ -919,17 +1423,23 @@ export default class User extends AbleObject<UserData> {
919
1423
  * @returns True if the passwords match, otherwise false.
920
1424
  */
921
1425
  public $checkPassword(password: string) {
922
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1426
+ const tilmeld = enforceTilmeld(this);
923
1427
  switch (tilmeld.config.pwMethod) {
924
1428
  case 'plain':
925
1429
  return this.$data.password === password;
926
1430
  case 'digest':
927
- return this.$data.password === sha256(password).toString(Base64);
1431
+ return (
1432
+ this.$data.password ===
1433
+ crypto.createHash('sha256').update(password).digest('base64')
1434
+ );
928
1435
  case 'salt':
929
1436
  default:
930
1437
  return (
931
1438
  this.$data.password ===
932
- sha256(password + this.$data.salt).toString(Base64)
1439
+ crypto
1440
+ .createHash('sha256')
1441
+ .update(password + this.$data.salt)
1442
+ .digest('base64')
933
1443
  );
934
1444
  }
935
1445
  }
@@ -940,10 +1450,12 @@ export default class User extends AbleObject<UserData> {
940
1450
  * @param group The group.
941
1451
  * @returns True if the user wasn't in the group. The resulting array of groups if the user was.
942
1452
  */
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) {
1453
+ public async $delGroup(group: GroupClass & GroupData) {
1454
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1455
+ let groups = this.$data.groups || [];
1456
+ if (this.$data.groups != null && group.$inArray(groups)) {
1457
+ const newGroups: (GroupClass & GroupData)[] = [];
1458
+ for (let curGroup of groups) {
947
1459
  if (!group.$is(curGroup)) {
948
1460
  newGroups.push(curGroup);
949
1461
  }
@@ -960,7 +1472,7 @@ export default class User extends AbleObject<UserData> {
960
1472
  * @param group The group, or the group's GUID.
961
1473
  * @returns True or false.
962
1474
  */
963
- public $inGroup(group: (Group & GroupData) | string) {
1475
+ public async $inGroup(group: (GroupClass & GroupData) | string) {
964
1476
  if (typeof group === 'string') {
965
1477
  if (this.$data.group?.guid === group) {
966
1478
  return true;
@@ -983,16 +1495,19 @@ export default class User extends AbleObject<UserData> {
983
1495
  * @param group The group, or the group's GUID.
984
1496
  * @returns True or false.
985
1497
  */
986
- public $isDescendant(group: (Group & GroupData) | string) {
1498
+ public async $isDescendant(group: (GroupClass & GroupData) | string) {
987
1499
  // Check to see if the user is in a descendant group of the given group.
1500
+ await this.$data.group?.$wake();
988
1501
  if (
1502
+ this.$data.group != null &&
989
1503
  this.$data.group?.cdate != null &&
990
- this.$data.group.$isDescendant(group)
1504
+ (await this.$data.group?.$isDescendant(group))
991
1505
  ) {
992
1506
  return true;
993
1507
  }
1508
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
994
1509
  for (let curGroup of this.$data.groups ?? []) {
995
- if (curGroup.$isDescendant(group)) {
1510
+ if (await curGroup.$isDescendant(group)) {
996
1511
  return true;
997
1512
  }
998
1513
  }
@@ -1008,15 +1523,31 @@ export default class User extends AbleObject<UserData> {
1008
1523
  public async $changePassword(data: {
1009
1524
  newPassword: string;
1010
1525
  currentPassword: string;
1526
+ revokeCurrentTokens?: boolean;
1011
1527
  }): Promise<{ result: boolean; message: string }> {
1528
+ this.$nymph.config.debugLog(
1529
+ 'tilmeld',
1530
+ `Password change request for "${this.$data.username}".`,
1531
+ );
1012
1532
  if (!('newPassword' in data) || !data.newPassword.length) {
1013
1533
  return { result: false, message: 'Please specify a password.' };
1014
1534
  }
1015
1535
  if (!this.$checkPassword(data.currentPassword ?? '')) {
1536
+ this.$nymph.config.debugLog(
1537
+ 'tilmeld',
1538
+ `Incorrect password for change request for "${this.$data.username}".`,
1539
+ );
1016
1540
  return { result: false, message: 'Incorrect password.' };
1017
1541
  }
1018
1542
  this.$data.passwordTemp = data.newPassword;
1543
+ if (data.revokeCurrentTokens) {
1544
+ this.$data.revokeTokenDate = Date.now();
1545
+ }
1019
1546
  if (await this.$save()) {
1547
+ if (data.revokeCurrentTokens) {
1548
+ const tilmeld = enforceTilmeld(this);
1549
+ await tilmeld.login(this, true);
1550
+ }
1020
1551
  return { result: true, message: 'Your password has been changed.' };
1021
1552
  } else {
1022
1553
  return { result: false, message: "Couldn't save new password." };
@@ -1030,7 +1561,7 @@ export default class User extends AbleObject<UserData> {
1030
1561
  * @returns The resulting password or hash which is stored in the entity.
1031
1562
  */
1032
1563
  public $password(password: string) {
1033
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1564
+ const tilmeld = enforceTilmeld(this);
1034
1565
  switch (tilmeld.config.pwMethod) {
1035
1566
  case 'plain':
1036
1567
  delete this.$data.salt;
@@ -1038,34 +1569,275 @@ export default class User extends AbleObject<UserData> {
1038
1569
  break;
1039
1570
  case 'digest':
1040
1571
  delete this.$data.salt;
1041
- this.$data.password = sha256(password).toString(Base64);
1572
+ this.$data.password = crypto
1573
+ .createHash('sha256')
1574
+ .update(password)
1575
+ .digest('base64');
1042
1576
  break;
1043
1577
  case 'salt':
1044
1578
  default:
1045
1579
  this.$data.salt = nanoid();
1046
- this.$data.password = sha256(password + this.$data.salt).toString(
1047
- Base64
1048
- );
1580
+ this.$data.password = crypto
1581
+ .createHash('sha256')
1582
+ .update(password + this.$data.salt)
1583
+ .digest('base64');
1049
1584
  break;
1050
1585
  }
1051
1586
  return this.$data.password;
1052
1587
  }
1053
1588
 
1589
+ /**
1590
+ * A frontend accessible method to revoke all currently issued tokens.
1591
+ *
1592
+ * @param data The input data from the client.
1593
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1594
+ */
1595
+ public async $revokeCurrentTokens(data: {
1596
+ password: string;
1597
+ }): Promise<{ result: boolean; message: string }> {
1598
+ if (!this.$checkPassword(data.password ?? '')) {
1599
+ return { result: false, message: 'Incorrect password.' };
1600
+ }
1601
+
1602
+ this.$data.revokeTokenDate = Date.now();
1603
+
1604
+ if (await this.$save()) {
1605
+ const tilmeld = enforceTilmeld(this);
1606
+ await tilmeld.login(this, true);
1607
+ return {
1608
+ result: true,
1609
+ message: 'You have logged out of all other sessions.',
1610
+ };
1611
+ } else {
1612
+ return { result: false, message: "Couldn't save revocation date." };
1613
+ }
1614
+ }
1615
+
1616
+ public async $hasTOTPSecret() {
1617
+ return this.$data.totpSecret != null;
1618
+ }
1619
+
1620
+ /**
1621
+ * Check the given code against the user's TOTP secret.
1622
+ *
1623
+ * @param code The code in question.
1624
+ * @returns True if the code is valid, otherwise false.
1625
+ */
1626
+ public $checkTOTPCode(code: string) {
1627
+ if (this.$data.totpSecret == null) {
1628
+ return false;
1629
+ }
1630
+
1631
+ const tilmeld = enforceTilmeld(this);
1632
+
1633
+ const totp = new TOTP({
1634
+ issuer: tilmeld.config.appName,
1635
+ label: this.$data.username,
1636
+ algorithm: 'SHA1',
1637
+ digits: 6,
1638
+ period: 30,
1639
+ secret: this.$data.totpSecret,
1640
+ });
1641
+
1642
+ return totp.validate({ token: code, window: 2 }) != null;
1643
+ }
1644
+
1645
+ /**
1646
+ * A frontend accessible method to generate a new TOTP secret.
1647
+ *
1648
+ * @returns An object with 'uri', 'qrcode', and 'secret'.
1649
+ */
1650
+ public async $getNewTOTPSecret() {
1651
+ const tilmeld = enforceTilmeld(this);
1652
+
1653
+ if (this.$data.totpSecret != null) {
1654
+ throw new BadDataError('You already have a 2FA secret.');
1655
+ }
1656
+
1657
+ const secret = new Secret();
1658
+
1659
+ const totp = new TOTP({
1660
+ issuer: tilmeld.config.appName,
1661
+ label: this.$data.username,
1662
+ algorithm: 'SHA1',
1663
+ digits: 6,
1664
+ period: 30,
1665
+ secret,
1666
+ });
1667
+
1668
+ const uri = totp.toString();
1669
+
1670
+ const qrcode = await toDataURL(uri);
1671
+
1672
+ return {
1673
+ uri,
1674
+ qrcode,
1675
+ secret: secret.base32,
1676
+ };
1677
+ }
1678
+
1679
+ /**
1680
+ * A frontend accessible method to save a TOTP secret into the user's account.
1681
+ *
1682
+ * @param data The input data from the client.
1683
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1684
+ */
1685
+ public async $saveTOTPSecret(data: {
1686
+ password: string;
1687
+ secret: string;
1688
+ code: string;
1689
+ }): Promise<{ result: boolean; message: string }> {
1690
+ if (!this.$checkPassword(data.password ?? '')) {
1691
+ return { result: false, message: 'Incorrect password.' };
1692
+ }
1693
+
1694
+ if (this.$data.totpSecret != null) {
1695
+ return { result: false, message: 'You already have a 2FA secret.' };
1696
+ }
1697
+
1698
+ if (
1699
+ data.secret == null ||
1700
+ typeof data.secret !== 'string' ||
1701
+ data.secret === ''
1702
+ ) {
1703
+ return { result: false, message: '2FA secret is invalid.' };
1704
+ }
1705
+
1706
+ if (
1707
+ data.code == null ||
1708
+ typeof data.code !== 'string' ||
1709
+ data.code.length !== 6
1710
+ ) {
1711
+ return { result: false, message: '2FA code is invalid.' };
1712
+ }
1713
+
1714
+ const tilmeld = enforceTilmeld(this);
1715
+
1716
+ const totp = new TOTP({
1717
+ issuer: tilmeld.config.appName,
1718
+ label: this.$data.username,
1719
+ algorithm: 'SHA1',
1720
+ digits: 6,
1721
+ period: 30,
1722
+ secret: data.secret,
1723
+ });
1724
+
1725
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1726
+ return { result: false, message: '2FA code is incorrect.' };
1727
+ }
1728
+
1729
+ this.$data.totpSecret = data.secret;
1730
+
1731
+ if (await this.$save()) {
1732
+ return { result: true, message: 'Your two factor secret is now set.' };
1733
+ } else {
1734
+ return { result: false, message: "Couldn't save two factor secret." };
1735
+ }
1736
+ }
1737
+
1738
+ /**
1739
+ * A frontend accessible method to remove the TOTP secret from the user's
1740
+ * account.
1741
+ *
1742
+ * @param data The input data from the client.
1743
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1744
+ */
1745
+ public async $removeTOTPSecret(data?: {
1746
+ password: string;
1747
+ code: string;
1748
+ }): Promise<{ result: boolean; message: string }> {
1749
+ const tilmeld = enforceTilmeld(this);
1750
+
1751
+ if (data) {
1752
+ if (!this.$checkPassword(data.password ?? '')) {
1753
+ return { result: false, message: 'Incorrect password.' };
1754
+ }
1755
+
1756
+ if (
1757
+ data.code == null ||
1758
+ typeof data.code !== 'string' ||
1759
+ data.code.length !== 6
1760
+ ) {
1761
+ return { result: false, message: '2FA code is invalid.' };
1762
+ }
1763
+
1764
+ if (this.$data.totpSecret == null) {
1765
+ return { result: false, message: "You don't have a 2FA secret." };
1766
+ }
1767
+
1768
+ const totp = new TOTP({
1769
+ issuer: tilmeld.config.appName,
1770
+ label: this.$data.username,
1771
+ algorithm: 'SHA1',
1772
+ digits: 6,
1773
+ period: 30,
1774
+ secret: this.$data.totpSecret,
1775
+ });
1776
+
1777
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1778
+ return { result: false, message: '2FA code is incorrect.' };
1779
+ }
1780
+ } else if (!tilmeld.gatekeeper('tilmeld/admin')) {
1781
+ return {
1782
+ result: false,
1783
+ message: 'You must provide your password and 2FA code.',
1784
+ };
1785
+ }
1786
+
1787
+ delete this.$data.totpSecret;
1788
+
1789
+ if (await this.$save()) {
1790
+ return {
1791
+ result: true,
1792
+ message: 'Two factor secret has been removed.',
1793
+ };
1794
+ } else {
1795
+ return { result: false, message: "Couldn't remove two factor secret." };
1796
+ }
1797
+ }
1798
+
1054
1799
  /**
1055
1800
  * Check that a username is valid.
1056
1801
  *
1057
1802
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1058
1803
  */
1059
1804
  public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1060
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1805
+ const tilmeld = enforceTilmeld(this);
1806
+
1807
+ this.$nymph.config.debugInfo(
1808
+ 'tilmeld',
1809
+ `Username check "${this.$data.username}".`,
1810
+ );
1811
+
1061
1812
  if (!tilmeld.config.emailUsernames) {
1062
- if (this.$data.username == null || !this.$data.username.length) {
1063
- return { result: false, message: 'Please specify a username.' };
1813
+ let [username, domain] = tilmeld.config.domainSupport
1814
+ ? splitn(this.$data.username ?? '', '@', 2)
1815
+ : [this.$data.username, null];
1816
+
1817
+ // Lowercase the domain part.
1818
+ if (domain) {
1819
+ domain = domain.toLowerCase();
1820
+ this.$data.username = `${username}@${domain}`;
1064
1821
  }
1822
+
1065
1823
  if (
1066
- tilmeld.config.maxUsernameLength > 0 &&
1067
- this.$data.username.length > tilmeld.config.maxUsernameLength
1824
+ this.$data.username == null ||
1825
+ !this.$data.username.length ||
1826
+ username == null ||
1827
+ !username.length
1068
1828
  ) {
1829
+ return { result: false, message: 'Please specify a username.' };
1830
+ }
1831
+ if (username.length < tilmeld.config.minUsernameLength) {
1832
+ return {
1833
+ result: false,
1834
+ message:
1835
+ 'Usernames must be at least ' +
1836
+ tilmeld.config.minUsernameLength +
1837
+ ' characters.',
1838
+ };
1839
+ }
1840
+ if (username.length > tilmeld.config.maxUsernameLength) {
1069
1841
  return {
1070
1842
  result: false,
1071
1843
  message:
@@ -1076,22 +1848,38 @@ export default class User extends AbleObject<UserData> {
1076
1848
  }
1077
1849
 
1078
1850
  if (
1079
- difference(
1080
- this.$data.username.split(''),
1081
- tilmeld.config.validChars.split('')
1082
- ).length
1851
+ difference(username.split(''), tilmeld.config.validChars.split(''))
1852
+ .length
1083
1853
  ) {
1084
1854
  return {
1085
1855
  result: false,
1086
1856
  message: tilmeld.config.validCharsNotice,
1087
1857
  };
1088
1858
  }
1089
- if (!tilmeld.config.validRegex.test(this.$data.username)) {
1859
+ if (!tilmeld.config.validRegex.test(username)) {
1090
1860
  return {
1091
1861
  result: false,
1092
1862
  message: tilmeld.config.validRegexNotice,
1093
1863
  };
1094
1864
  }
1865
+ if (domain != null) {
1866
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
1867
+ return {
1868
+ result: false,
1869
+ message: tilmeld.config.validDomainRegexNotice,
1870
+ };
1871
+ }
1872
+ if (
1873
+ !this.$is(tilmeld.currentUser) &&
1874
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
1875
+ ) {
1876
+ return {
1877
+ result: false,
1878
+ message:
1879
+ "You don't have the authority to manage users on this domain.",
1880
+ };
1881
+ }
1882
+ }
1095
1883
 
1096
1884
  const selector: Selector = {
1097
1885
  type: '&',
@@ -1105,12 +1893,24 @@ export default class User extends AbleObject<UserData> {
1105
1893
  }
1106
1894
  const test = await this.$nymph.getEntity(
1107
1895
  { class: tilmeld.User, skipAc: true },
1108
- selector
1896
+ selector,
1109
1897
  );
1110
1898
  if (test != null) {
1111
1899
  return { result: false, message: 'That username is taken.' };
1112
1900
  }
1113
1901
 
1902
+ for (let callback of (this.constructor as typeof User)
1903
+ .checkUsernameCallbacks) {
1904
+ if (callback) {
1905
+ const result = await callback(this, {
1906
+ username: this.$data.username,
1907
+ });
1908
+ if (!result.result) {
1909
+ return { message: 'Username is not available.', ...result };
1910
+ }
1911
+ }
1912
+ }
1913
+
1114
1914
  return {
1115
1915
  result: true,
1116
1916
  message:
@@ -1121,10 +1921,16 @@ export default class User extends AbleObject<UserData> {
1121
1921
  if (this.$data.username == null || !this.$data.username.length) {
1122
1922
  return { result: false, message: 'Please specify an email.' };
1123
1923
  }
1124
- if (
1125
- tilmeld.config.maxUsernameLength > 0 &&
1126
- this.$data.username.length > tilmeld.config.maxUsernameLength
1127
- ) {
1924
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1925
+ return {
1926
+ result: false,
1927
+ message:
1928
+ 'Emails must be at least ' +
1929
+ tilmeld.config.minUsernameLength +
1930
+ ' characters.',
1931
+ };
1932
+ }
1933
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1128
1934
  return {
1129
1935
  result: false,
1130
1936
  message:
@@ -1144,7 +1950,13 @@ export default class User extends AbleObject<UserData> {
1144
1950
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1145
1951
  */
1146
1952
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
1147
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1953
+ const tilmeld = enforceTilmeld(this);
1954
+
1955
+ this.$nymph.config.debugInfo(
1956
+ 'tilmeld',
1957
+ `Email check "${this.$data.email}".`,
1958
+ );
1959
+
1148
1960
  if (this.$data.email == null || !this.$data.email.length) {
1149
1961
  if (tilmeld.config.verifyEmail) {
1150
1962
  return { result: false, message: 'Please specify an email.' };
@@ -1167,7 +1979,7 @@ export default class User extends AbleObject<UserData> {
1167
1979
  }
1168
1980
  const test = await this.$nymph.getEntity(
1169
1981
  { class: tilmeld.User, skipAc: true },
1170
- selector
1982
+ selector,
1171
1983
  );
1172
1984
  if (test != null) {
1173
1985
  return {
@@ -1189,7 +2001,13 @@ export default class User extends AbleObject<UserData> {
1189
2001
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1190
2002
  */
1191
2003
  public async $checkPhone(): Promise<{ result: boolean; message: string }> {
1192
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
2004
+ const tilmeld = enforceTilmeld(this);
2005
+
2006
+ this.$nymph.config.debugInfo(
2007
+ 'tilmeld',
2008
+ `Phone check "${this.$data.phone}".`,
2009
+ );
2010
+
1193
2011
  if (this.$data.phone == null || !this.$data.phone.length) {
1194
2012
  return { result: false, message: 'Please specify a phone number.' };
1195
2013
  }
@@ -1211,7 +2029,7 @@ export default class User extends AbleObject<UserData> {
1211
2029
  }
1212
2030
  const test = await this.$nymph.getEntity(
1213
2031
  { class: tilmeld.User, skipAc: true },
1214
- selector
2032
+ selector,
1215
2033
  );
1216
2034
  if (test != null) {
1217
2035
  return { result: false, message: 'Phone number is in use.' };
@@ -1228,7 +2046,7 @@ export default class User extends AbleObject<UserData> {
1228
2046
  password: string;
1229
2047
  additionalData?: { [k: string]: any };
1230
2048
  }): Promise<{ result: boolean; loggedin: boolean; message: string }> {
1231
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
2049
+ let tilmeld = enforceTilmeld(this);
1232
2050
  if (!tilmeld.config.allowRegistration) {
1233
2051
  return {
1234
2052
  result: false,
@@ -1244,21 +2062,6 @@ export default class User extends AbleObject<UserData> {
1244
2062
  };
1245
2063
  }
1246
2064
 
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
2065
  if (!('password' in data) || !data.password.length) {
1263
2066
  return {
1264
2067
  result: false,
@@ -1272,7 +2075,7 @@ export default class User extends AbleObject<UserData> {
1272
2075
  }
1273
2076
 
1274
2077
  this.$password(data.password);
1275
- if (tilmeld.config.regFields.indexOf('name') !== -1) {
2078
+ if (tilmeld.config.regFields.includes('name')) {
1276
2079
  this.$data.name =
1277
2080
  this.$data.nameFirst +
1278
2081
  (this.$data.nameMiddle == null ? '' : ' ' + this.$data.nameMiddle) +
@@ -1289,75 +2092,34 @@ export default class User extends AbleObject<UserData> {
1289
2092
  const transaction = 'tilmeld-register-' + nanoid();
1290
2093
  const nymph = this.$nymph;
1291
2094
  const tnymph = await this.$nymph.startTransaction(transaction);
1292
- this.$nymph = tnymph;
1293
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2095
+ this.$setNymph(tnymph);
2096
+ tilmeld = enforceTilmeld(this);
2097
+
2098
+ let message = '';
2099
+ let loggedin = false;
1294
2100
 
1295
2101
  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],
2102
+ try {
2103
+ for (let callback of (this.constructor as typeof User)
2104
+ .beforeRegisterCallbacks) {
2105
+ if (callback) {
2106
+ await callback(this, data);
1332
2107
  }
1333
- );
1334
- if (group != null) {
1335
- this.$data.group = group;
1336
2108
  }
2109
+ } catch (e: any) {
2110
+ await tnymph.rollback(transaction);
2111
+ this.$setNymph(nymph);
2112
+ return {
2113
+ result: false,
2114
+ loggedin: false,
2115
+ message: e.message,
2116
+ };
1337
2117
  }
1338
2118
 
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) {
2119
+ if (
2120
+ tilmeld.config.userFields.includes('email') &&
2121
+ tilmeld.config.verifyEmail
2122
+ ) {
1361
2123
  // The user will be enabled after verifying their e-mail address.
1362
2124
  if (!tilmeld.config.unverifiedAccess) {
1363
2125
  this.$data.enabled = false;
@@ -1366,7 +2128,7 @@ export default class User extends AbleObject<UserData> {
1366
2128
  this.$data.enabled = true;
1367
2129
  }
1368
2130
 
1369
- // If create_admin is true and there are no other users, grant
2131
+ // If createAdmin is true and there are no other users, grant
1370
2132
  // "system/admin".
1371
2133
  let madeAdmin = false;
1372
2134
  if (tilmeld.config.createAdmin) {
@@ -1384,7 +2146,15 @@ export default class User extends AbleObject<UserData> {
1384
2146
  }
1385
2147
  }
1386
2148
 
2149
+ this.$nymph.config.debugLog(
2150
+ 'tilmeld',
2151
+ `Registering new user "${this.$data.username}".`,
2152
+ );
1387
2153
  if (await this.$saveSkipAC()) {
2154
+ this.$nymph.config.debugLog(
2155
+ 'tilmeld',
2156
+ `New user registered "${this.$data.username}".`,
2157
+ );
1388
2158
  // Send the new user registered email.
1389
2159
  if (tilmeld.config.userRegisteredRecipient != null) {
1390
2160
  await tilmeld.config.sendEmail(
@@ -1403,33 +2173,16 @@ export default class User extends AbleObject<UserData> {
1403
2173
  userPhone: this.$data.phone,
1404
2174
  },
1405
2175
  },
1406
- this
2176
+ this,
1407
2177
  );
1408
2178
  }
1409
2179
 
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
2180
  // Finish up.
1429
2181
  if (
1430
2182
  tilmeld.config.verifyEmail &&
1431
2183
  !tilmeld.config.unverifiedAccess &&
1432
- !madeAdmin
2184
+ !madeAdmin &&
2185
+ tilmeld.config.userFields.includes('email')
1433
2186
  ) {
1434
2187
  message +=
1435
2188
  `Almost there. An email has been sent to ${this.$data.email} ` +
@@ -1437,9 +2190,10 @@ export default class User extends AbleObject<UserData> {
1437
2190
  } else if (
1438
2191
  tilmeld.config.verifyEmail &&
1439
2192
  tilmeld.config.unverifiedAccess &&
1440
- !madeAdmin
2193
+ !madeAdmin &&
2194
+ tilmeld.config.userFields.includes('email')
1441
2195
  ) {
1442
- if (!tilmeld.login(this, true)) {
2196
+ if (!(await tilmeld.login(this, true))) {
1443
2197
  throw new Error('An error occurred trying to log you in.');
1444
2198
  }
1445
2199
  message +=
@@ -1448,7 +2202,7 @@ export default class User extends AbleObject<UserData> {
1448
2202
  'registration.';
1449
2203
  loggedin = true;
1450
2204
  } else {
1451
- if (!tilmeld.login(this, true)) {
2205
+ if (!(await tilmeld.login(this, true))) {
1452
2206
  throw new Error('An error occurred trying to log you in.');
1453
2207
  }
1454
2208
  message += "You're now registered and logged in!";
@@ -1464,23 +2218,13 @@ export default class User extends AbleObject<UserData> {
1464
2218
  });
1465
2219
  }
1466
2220
  }
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
2221
  } else {
2222
+ this.$nymph.config.debugError(
2223
+ 'tilmeld',
2224
+ `Error registering new user "${this.$data.username}".`,
2225
+ );
1482
2226
  await tnymph.rollback(transaction);
1483
- this.$nymph = nymph;
2227
+ this.$setNymph(nymph);
1484
2228
  return {
1485
2229
  result: false,
1486
2230
  loggedin: false,
@@ -1488,28 +2232,100 @@ export default class User extends AbleObject<UserData> {
1488
2232
  };
1489
2233
  }
1490
2234
  } catch (e: any) {
2235
+ this.$nymph.config.debugError(
2236
+ 'tilmeld',
2237
+ `Error registering new user "${this.$data.username}": ${e}`,
2238
+ );
1491
2239
  await tnymph.rollback(transaction);
1492
- this.$nymph = nymph;
2240
+ this.$setNymph(nymph);
1493
2241
  throw e;
1494
2242
  }
2243
+
2244
+ try {
2245
+ await tnymph.commit(transaction);
2246
+ this.$setNymph(nymph);
2247
+ await this.$nymph.tilmeld?.fillSession(this);
2248
+ } catch (e: any) {
2249
+ throw e;
2250
+ }
2251
+
2252
+ return {
2253
+ result: true,
2254
+ loggedin,
2255
+ message,
2256
+ };
1495
2257
  }
1496
2258
 
1497
2259
  public async $save() {
1498
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
2260
+ let tilmeld = enforceTilmeld(this);
1499
2261
  if (this.$data.username == null || !this.$data.username.trim().length) {
1500
2262
  return false;
1501
2263
  }
1502
2264
 
1503
2265
  if (
2266
+ !this.$skipAcWhenSaving &&
1504
2267
  tilmeld.gatekeeper('tilmeld/admin') &&
1505
2268
  !tilmeld.gatekeeper('system/admin') &&
1506
- this.$gatekeeper('system/admin')
2269
+ this.$data.abilities?.includes('system/admin')
1507
2270
  ) {
1508
- throw new BadDataError(
1509
- "You don't have the authority to modify system admins."
2271
+ throw new AccessControlError(
2272
+ "You don't have the authority to modify system admins.",
1510
2273
  );
1511
2274
  }
1512
2275
 
2276
+ // Lowercase the domain part.
2277
+ if (tilmeld.config.domainSupport) {
2278
+ const [username, domain] = splitn(this.$data.username ?? '', '@', 2);
2279
+ if (domain) {
2280
+ this.$data.username = `${username}@${domain.toLowerCase()}`;
2281
+ }
2282
+ }
2283
+
2284
+ if (tilmeld.config.domainSupport) {
2285
+ const [_username, domain] = splitn(this.$data.username ?? '', '@', 2);
2286
+ const [_originalUsername, originalDomain] = splitn(
2287
+ this.$originalUsername ?? '',
2288
+ '@',
2289
+ 2,
2290
+ );
2291
+
2292
+ if (
2293
+ this.guid != null &&
2294
+ ((originalDomain == null && domain != null) ||
2295
+ (domain == null && originalDomain != null))
2296
+ ) {
2297
+ throw new AccessControlError(
2298
+ "Users can't switch to or from having a domain once created.",
2299
+ );
2300
+ }
2301
+
2302
+ if (
2303
+ !this.$skipAcWhenSaving &&
2304
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2305
+ !tilmeld.gatekeeper('system/admin')
2306
+ ) {
2307
+ if (
2308
+ domain != null &&
2309
+ !this.$is(tilmeld.currentUser) &&
2310
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
2311
+ ) {
2312
+ throw new AccessControlError(
2313
+ "You don't have the authority to modify users on this domain.",
2314
+ );
2315
+ }
2316
+ if (
2317
+ originalDomain != null &&
2318
+ originalDomain !== domain &&
2319
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
2320
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
2321
+ ) {
2322
+ throw new AccessControlError(
2323
+ "You don't have the authority to modify users on this domain.",
2324
+ );
2325
+ }
2326
+ }
2327
+ }
2328
+
1513
2329
  let sendVerification = false;
1514
2330
 
1515
2331
  // Formatting.
@@ -1528,49 +2344,88 @@ export default class User extends AbleObject<UserData> {
1528
2344
  this.$data.enabled = !!this.$data.enabled;
1529
2345
 
1530
2346
  // Clear empty values.
1531
- if (this.$data.nameFirst === '') {
2347
+ if (
2348
+ this.$data.nameFirst === '' ||
2349
+ !tilmeld.config.userFields.includes('name')
2350
+ ) {
1532
2351
  delete this.$data.nameFirst;
1533
2352
  }
1534
- if (this.$data.nameMiddle === '') {
2353
+ if (
2354
+ this.$data.nameMiddle === '' ||
2355
+ !tilmeld.config.userFields.includes('name')
2356
+ ) {
1535
2357
  delete this.$data.nameMiddle;
1536
2358
  }
1537
- if (this.$data.nameLast === '') {
2359
+ if (
2360
+ this.$data.nameLast === '' ||
2361
+ !tilmeld.config.userFields.includes('name')
2362
+ ) {
1538
2363
  delete this.$data.nameLast;
1539
2364
  }
1540
- if (this.$data.name === '') {
2365
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
1541
2366
  delete this.$data.name;
1542
2367
  }
1543
- if (this.$data.email === '') {
2368
+ if (
2369
+ this.$data.email === '' ||
2370
+ !tilmeld.config.userFields.includes('email')
2371
+ ) {
1544
2372
  delete this.$data.email;
1545
2373
  }
1546
2374
  if (this.$data.avatar === '') {
1547
2375
  delete this.$data.avatar;
1548
2376
  }
1549
- if (this.$data.phone === '') {
2377
+ if (
2378
+ this.$data.phone === '' ||
2379
+ !tilmeld.config.userFields.includes('phone')
2380
+ ) {
1550
2381
  delete this.$data.phone;
1551
2382
  }
1552
- if (this.$data.secret === '') {
2383
+ if (
2384
+ this.$data.secret === '' ||
2385
+ !tilmeld.config.userFields.includes('email')
2386
+ ) {
1553
2387
  delete this.$data.secret;
1554
2388
  }
1555
- if (this.$data.emailChangeDate === 0) {
2389
+ if (
2390
+ this.$data.emailChangeDate === 0 ||
2391
+ !tilmeld.config.userFields.includes('email')
2392
+ ) {
1556
2393
  delete this.$data.emailChangeDate;
1557
2394
  }
1558
- if (this.$data.newEmailSecret === '') {
2395
+ if (
2396
+ this.$data.newEmailSecret === '' ||
2397
+ !tilmeld.config.userFields.includes('email')
2398
+ ) {
1559
2399
  delete this.$data.newEmailSecret;
1560
2400
  }
1561
- if (this.$data.newEmailAddress === '') {
2401
+ if (
2402
+ this.$data.newEmailAddress === '' ||
2403
+ !tilmeld.config.userFields.includes('email')
2404
+ ) {
1562
2405
  delete this.$data.newEmailAddress;
1563
2406
  }
1564
- if (this.$data.cancelEmailSecret === '') {
2407
+ if (
2408
+ this.$data.cancelEmailSecret === '' ||
2409
+ !tilmeld.config.userFields.includes('email')
2410
+ ) {
1565
2411
  delete this.$data.cancelEmailSecret;
1566
2412
  }
1567
- if (this.$data.cancelEmailAddress === '') {
2413
+ if (
2414
+ this.$data.cancelEmailAddress === '' ||
2415
+ !tilmeld.config.userFields.includes('email')
2416
+ ) {
1568
2417
  delete this.$data.cancelEmailAddress;
1569
2418
  }
1570
- if (this.$data.recoverSecret === '') {
2419
+ if (
2420
+ this.$data.recoverSecret === '' ||
2421
+ !tilmeld.config.userFields.includes('email')
2422
+ ) {
1571
2423
  delete this.$data.recoverSecret;
1572
2424
  }
1573
- if (this.$data.recoverSecretDate === 0) {
2425
+ if (
2426
+ this.$data.recoverSecretDate === 0 ||
2427
+ !tilmeld.config.userFields.includes('email')
2428
+ ) {
1574
2429
  delete this.$data.recoverSecretDate;
1575
2430
  }
1576
2431
  if (this.$data.password === '') {
@@ -1588,7 +2443,10 @@ export default class User extends AbleObject<UserData> {
1588
2443
  if (!unCheck.result) {
1589
2444
  throw new BadUsernameError(unCheck.message);
1590
2445
  }
1591
- if (!tilmeld.config.emailUsernames) {
2446
+ if (
2447
+ !tilmeld.config.emailUsernames &&
2448
+ tilmeld.config.userFields.includes('email')
2449
+ ) {
1592
2450
  const emCheck = await this.$checkEmail();
1593
2451
  if (!emCheck.result) {
1594
2452
  throw new BadEmailError(emCheck.message);
@@ -1596,7 +2454,10 @@ export default class User extends AbleObject<UserData> {
1596
2454
  }
1597
2455
 
1598
2456
  // Email changes.
1599
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
2457
+ if (
2458
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2459
+ tilmeld.config.userFields.includes('email')
2460
+ ) {
1600
2461
  // The user isn't an admin, so email address changes should contain some
1601
2462
  // security measures.
1602
2463
  if (tilmeld.config.verifyEmail) {
@@ -1604,7 +2465,7 @@ export default class User extends AbleObject<UserData> {
1604
2465
  if (this.guid == null) {
1605
2466
  // If this is the first user, they'll be made an admin, so they don't
1606
2467
  // need to verify.
1607
- if ((this.$data.abilities?.indexOf('system/admin') ?? -1) === -1) {
2468
+ if (!this.$data.abilities?.includes('system/admin')) {
1608
2469
  this.$data.secret = nanoid();
1609
2470
  sendVerification = true;
1610
2471
  }
@@ -1624,10 +2485,10 @@ export default class User extends AbleObject<UserData> {
1624
2485
  new Date(
1625
2486
  strtotime(
1626
2487
  '+' + tilmeld.config.emailRateLimit,
1627
- Math.floor(this.$data.emailChangeDate / 1000)
1628
- ) * 1000
2488
+ Math.floor(this.$data.emailChangeDate / 1000),
2489
+ ) * 1000,
1629
2490
  ).toString() +
1630
- ' to change your email address again.'
2491
+ ' to change your email address again.',
1631
2492
  );
1632
2493
  } else {
1633
2494
  if (
@@ -1682,51 +2543,148 @@ export default class User extends AbleObject<UserData> {
1682
2543
  throw new BadDataError('A password is required.');
1683
2544
  }
1684
2545
 
1685
- try {
1686
- tilmeld.config.validatorUser(this);
1687
- } catch (e: any) {
1688
- throw new BadDataError(e?.message);
1689
- }
1690
-
1691
2546
  // Start transaction.
1692
- const transaction = 'tilmeld-save-' + nanoid();
2547
+ const transaction = 'tilmeld-save-user-' + (this.guid || nanoid());
1693
2548
  const nymph = this.$nymph;
1694
2549
  const tnymph = await this.$nymph.startTransaction(transaction);
1695
- this.$nymph = tnymph;
1696
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2550
+ this.$setNymph(tnymph);
2551
+ tilmeld = enforceTilmeld(this);
2552
+ const Group = tnymph.getEntityClass(GroupClass);
1697
2553
 
1698
- if (
1699
- this.$data.group != null &&
1700
- this.$data.group.user != null &&
1701
- this.$is(this.$data.group.user)
1702
- ) {
1703
- try {
2554
+ try {
2555
+ let group = this.$data.group;
2556
+ await group?.$wake();
2557
+ if (group == null && this.guid == null) {
2558
+ if (tilmeld.config.generatePrimary) {
2559
+ // Generate a new primary group for the user.
2560
+ group = await Group.factory();
2561
+ group.user = this;
2562
+ const parent = await tnymph.getEntity(
2563
+ { class: Group },
2564
+ {
2565
+ type: '&',
2566
+ equal: ['defaultPrimary', true],
2567
+ },
2568
+ );
2569
+ if (parent != null) {
2570
+ group.parent = parent;
2571
+ }
2572
+ } else {
2573
+ // Add the default primary.
2574
+ const group = await tnymph.getEntity(
2575
+ { class: Group },
2576
+ {
2577
+ type: '&',
2578
+ equal: ['defaultPrimary', true],
2579
+ },
2580
+ );
2581
+ if (group != null) {
2582
+ this.$data.group = group;
2583
+ }
2584
+ }
2585
+ }
2586
+ if (group != null && group.user != null && this.$is(group.user)) {
1704
2587
  // 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();
2588
+ let changed = false;
2589
+ if (group.groupname !== this.$data.username) {
2590
+ group.groupname = this.$data.username;
2591
+ changed = true;
2592
+ }
2593
+ if (group.avatar !== this.$data.avatar) {
2594
+ group.avatar = this.$data.avatar;
2595
+ changed = true;
2596
+ }
2597
+ if (
2598
+ tilmeld.config.userFields.includes('email') &&
2599
+ group.email !== this.$data.email
2600
+ ) {
2601
+ group.email = this.$data.email;
2602
+ changed = true;
2603
+ }
2604
+ if (
2605
+ tilmeld.config.userFields.includes('name') &&
2606
+ group.name !== this.$data.name
2607
+ ) {
2608
+ group.name = this.$data.name;
2609
+ changed = true;
2610
+ }
2611
+ if (
2612
+ tilmeld.config.userFields.includes('phone') &&
2613
+ group.phone !== this.$data.phone
2614
+ ) {
2615
+ group.phone = this.$data.phone;
2616
+ changed = true;
2617
+ }
2618
+ if (changed || group.guid == null) {
2619
+ if (!(await group.$saveSkipAC())) {
2620
+ throw Error('Error updating primary group for user.');
2621
+ }
2622
+ }
2623
+ this.$data.group = group;
2624
+ }
2625
+
2626
+ if (this.$data.groups == null || this.$data.groups.length === 0) {
2627
+ // Add secondary groups.
2628
+ if (
2629
+ tilmeld.config.userFields.includes('email') &&
2630
+ tilmeld.config.verifyEmail &&
2631
+ tilmeld.config.unverifiedAccess &&
2632
+ this.$data.secret != null
2633
+ ) {
2634
+ // Add the default secondaries for unverified users.
2635
+ this.$data.groups = await tnymph.getEntities(
2636
+ { class: Group },
2637
+ {
2638
+ type: '&',
2639
+ equal: ['unverifiedSecondary', true],
2640
+ },
2641
+ );
2642
+ } else {
2643
+ // Add the default secondaries.
2644
+ this.$data.groups = await tnymph.getEntities(
2645
+ { class: Group },
2646
+ {
2647
+ type: '&',
2648
+ equal: ['defaultSecondary', true],
2649
+ },
2650
+ );
2651
+ }
2652
+ }
2653
+
2654
+ try {
2655
+ tilmeld.config.validatorUser(tilmeld, this);
1711
2656
  } catch (e: any) {
1712
- await tnymph.rollback(transaction);
1713
- this.$nymph = nymph;
1714
- throw e;
2657
+ throw new BadDataError(e?.message);
1715
2658
  }
2659
+ } catch (e: any) {
2660
+ await tnymph.rollback(transaction);
2661
+ this.$setNymph(nymph);
2662
+ throw e;
1716
2663
  }
1717
2664
 
1718
- let ret: boolean;
2665
+ let ret = false;
1719
2666
  let preGuid = this.guid;
1720
2667
  let preCdate = this.cdate;
1721
2668
  let preMdate = this.mdate;
1722
2669
 
1723
2670
  try {
2671
+ for (let callback of (this.constructor as typeof User)
2672
+ .beforeSaveCallbacks) {
2673
+ if (callback) {
2674
+ await callback(this);
2675
+ }
2676
+ }
2677
+
1724
2678
  ret = await super.$save();
1725
2679
  } catch (e: any) {
1726
2680
  await tnymph.rollback(transaction);
1727
- this.$nymph = nymph;
2681
+ this.guid = preGuid;
2682
+ this.cdate = preCdate;
2683
+ this.mdate = preMdate;
2684
+ this.$setNymph(nymph);
1728
2685
  throw e;
1729
2686
  }
2687
+
1730
2688
  if (ret) {
1731
2689
  if (sendVerification) {
1732
2690
  // The email has changed, so send a new verification email.
@@ -1735,23 +2693,48 @@ export default class User extends AbleObject<UserData> {
1735
2693
  this.guid = preGuid;
1736
2694
  this.cdate = preCdate;
1737
2695
  this.mdate = preMdate;
1738
- this.$nymph = nymph;
2696
+ this.$setNymph(nymph);
1739
2697
  throw new Error("Couldn't send verification email.");
1740
2698
  }
1741
2699
  }
1742
2700
 
1743
2701
  this.$descendantGroups = undefined;
1744
2702
  this.$gatekeeperCache = undefined;
1745
- await tnymph.commit(transaction);
1746
2703
 
1747
- if ((nymph.tilmeld as Tilmeld).User.current(true).$is(this)) {
1748
- // Update the user in the session cache.
1749
- (nymph.tilmeld as Tilmeld).fillSession(this);
2704
+ try {
2705
+ for (let callback of (this.constructor as typeof User)
2706
+ .afterSaveCallbacks) {
2707
+ if (callback) {
2708
+ await callback(this);
2709
+ }
2710
+ }
2711
+ } catch (e: any) {
2712
+ await tnymph.rollback(transaction);
2713
+ this.guid = preGuid;
2714
+ this.cdate = preCdate;
2715
+ this.mdate = preMdate;
2716
+ this.$setNymph(nymph);
2717
+ throw e;
1750
2718
  }
2719
+
2720
+ ret = await tnymph.commit(transaction);
1751
2721
  } else {
1752
2722
  await tnymph.rollback(transaction);
1753
2723
  }
1754
- this.$nymph = nymph;
2724
+ this.$setNymph(nymph);
2725
+
2726
+ if (ret) {
2727
+ this.$originalEmail = this.$data.email;
2728
+ this.$originalUsername = this.$data.username;
2729
+
2730
+ const tilmeld = enforceTilmeld(nymph);
2731
+
2732
+ if (tilmeld.User.current(true).$is(this)) {
2733
+ // Update the user in the session cache.
2734
+ await tilmeld.fillSession(this);
2735
+ }
2736
+ }
2737
+
1755
2738
  return ret;
1756
2739
  }
1757
2740
 
@@ -1772,53 +2755,153 @@ export default class User extends AbleObject<UserData> {
1772
2755
  }
1773
2756
 
1774
2757
  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.");
2758
+ const tilmeld = enforceTilmeld(this);
2759
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
2760
+ throw new AccessControlError(
2761
+ "You don't have the authority to delete users.",
2762
+ );
1778
2763
  }
1779
2764
  if (
2765
+ !this.$skipAcWhenDeleting &&
1780
2766
  !tilmeld.gatekeeper('system/admin') &&
1781
- this.$gatekeeper('system/admin')
2767
+ this.$data.abilities?.includes('system/admin')
1782
2768
  ) {
1783
- throw new BadDataError(
1784
- "You don't have the authority to delete system admins."
2769
+ throw new AccessControlError(
2770
+ "You don't have the authority to delete system admins.",
1785
2771
  );
1786
2772
  }
1787
- if (tilmeld.User.current(true).$is(this)) {
1788
- await this.$logout();
2773
+ if (this.$data.username !== this.$originalUsername) {
2774
+ throw new AccessControlError(
2775
+ "You can't delete a user while switching their username.",
2776
+ );
2777
+ }
2778
+
2779
+ const transaction = 'tilmeld-delete-user-' + this.guid;
2780
+ const nymph = this.$nymph;
2781
+ const tnymph = await nymph.startTransaction(transaction);
2782
+ this.$setNymph(tnymph);
2783
+
2784
+ let success = false;
2785
+
2786
+ try {
2787
+ for (let callback of (this.constructor as typeof User)
2788
+ .beforeDeleteCallbacks) {
2789
+ if (callback) {
2790
+ await callback(this);
2791
+ }
2792
+ }
2793
+
2794
+ if (tilmeld.User.current(true).$is(this)) {
2795
+ await this.$logout();
2796
+ }
2797
+
2798
+ if (this.$data.group != null && this.$is(this.$data.group.user)) {
2799
+ // Read the skip ac value here, because super.$delete changes it.
2800
+ const $skipAcWhenDeleting = this.$skipAcWhenDeleting;
2801
+
2802
+ // Delete the user.
2803
+ success = await super.$delete();
2804
+ if (success) {
2805
+ // Delete the generated group.
2806
+ success = $skipAcWhenDeleting
2807
+ ? await this.$data.group.$deleteSkipAC()
2808
+ : await this.$data.group.$delete();
2809
+ }
2810
+ } else {
2811
+ success = await super.$delete();
2812
+ }
2813
+ } catch (e: any) {
2814
+ await tnymph.rollback(transaction);
2815
+ this.$setNymph(nymph);
2816
+ throw e;
2817
+ }
2818
+
2819
+ if (success) {
2820
+ try {
2821
+ for (let callback of (this.constructor as typeof User)
2822
+ .afterDeleteCallbacks) {
2823
+ if (callback) {
2824
+ await callback(this);
2825
+ }
2826
+ }
2827
+ } catch (e: any) {
2828
+ await tnymph.rollback(transaction);
2829
+ this.$setNymph(nymph);
2830
+ throw e;
2831
+ }
2832
+ success = await tnymph.commit(transaction);
2833
+ } else {
2834
+ await tnymph.rollback(transaction);
2835
+ }
2836
+ this.$setNymph(nymph);
2837
+ return success;
2838
+ }
2839
+
2840
+ /*
2841
+ * This should *never* be accessible on the client.
2842
+ */
2843
+ async $deleteSkipAC() {
2844
+ this.$skipAcWhenDeleting = true;
2845
+ return await this.$delete();
2846
+ }
2847
+
2848
+ $tilmeldDeleteSkipAC() {
2849
+ if (this.$skipAcWhenDeleting) {
2850
+ this.$skipAcWhenDeleting = false;
2851
+ return true;
1789
2852
  }
1790
- return await super.$delete();
2853
+ return false;
1791
2854
  }
1792
2855
 
1793
2856
  public static on<T extends EventType>(
1794
2857
  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
2858
+ callback: T extends 'checkUsername'
2859
+ ? TilmeldCheckUsernameCallback
2860
+ : T extends 'beforeRegister'
2861
+ ? TilmeldBeforeRegisterCallback
2862
+ : T extends 'afterRegister'
2863
+ ? TilmeldAfterRegisterCallback
2864
+ : T extends 'beforeSave'
2865
+ ? TilmeldBeforeSaveCallback
2866
+ : T extends 'afterSave'
2867
+ ? TilmeldAfterSaveCallback
2868
+ : T extends 'beforeLogin'
2869
+ ? TilmeldBeforeLoginCallback
2870
+ : T extends 'afterLogin'
2871
+ ? TilmeldAfterLoginCallback
2872
+ : T extends 'beforeLogout'
2873
+ ? TilmeldBeforeLogoutCallback
2874
+ : T extends 'afterLogout'
2875
+ ? TilmeldAfterLogoutCallback
2876
+ : T extends 'beforeDelete'
2877
+ ? TilmeldBeforeDeleteCallback
2878
+ : T extends 'afterDelete'
2879
+ ? TilmeldAfterDeleteCallback
2880
+ : never,
1808
2881
  ) {
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;
2882
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2883
+ ? 'checkUsernameCallbacks'
2884
+ : T extends 'beforeRegister'
2885
+ ? 'beforeRegisterCallbacks'
2886
+ : T extends 'afterRegister'
2887
+ ? 'afterRegisterCallbacks'
2888
+ : T extends 'beforeSave'
2889
+ ? 'beforeSaveCallbacks'
2890
+ : T extends 'afterSave'
2891
+ ? 'afterSaveCallbacks'
2892
+ : T extends 'beforeLogin'
2893
+ ? 'beforeLoginCallbacks'
2894
+ : T extends 'afterLogin'
2895
+ ? 'afterLoginCallbacks'
2896
+ : T extends 'beforeLogout'
2897
+ ? 'beforeLogoutCallbacks'
2898
+ : T extends 'afterLogout'
2899
+ ? 'afterLogoutCallbacks'
2900
+ : T extends 'beforeDelete'
2901
+ ? 'beforeDeleteCallbacks'
2902
+ : T extends 'afterDelete'
2903
+ ? 'afterDeleteCallbacks'
2904
+ : never;
1822
2905
  if (!(prop in this)) {
1823
2906
  throw new Error('Invalid event type.');
1824
2907
  }
@@ -1829,33 +2912,53 @@ export default class User extends AbleObject<UserData> {
1829
2912
 
1830
2913
  public static off<T extends EventType>(
1831
2914
  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
2915
+ callback: T extends 'checkUsername'
2916
+ ? TilmeldCheckUsernameCallback
2917
+ : T extends 'beforeRegister'
2918
+ ? TilmeldBeforeRegisterCallback
2919
+ : T extends 'afterRegister'
2920
+ ? TilmeldAfterRegisterCallback
2921
+ : T extends 'beforeSave'
2922
+ ? TilmeldBeforeSaveCallback
2923
+ : T extends 'afterSave'
2924
+ ? TilmeldAfterSaveCallback
2925
+ : T extends 'beforeLogin'
2926
+ ? TilmeldBeforeLoginCallback
2927
+ : T extends 'afterLogin'
2928
+ ? TilmeldAfterLoginCallback
2929
+ : T extends 'beforeLogout'
2930
+ ? TilmeldBeforeLogoutCallback
2931
+ : T extends 'afterLogout'
2932
+ ? TilmeldAfterLogoutCallback
2933
+ : T extends 'beforeDelete'
2934
+ ? TilmeldBeforeDeleteCallback
2935
+ : T extends 'afterDelete'
2936
+ ? TilmeldAfterDeleteCallback
2937
+ : never,
1845
2938
  ) {
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;
2939
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2940
+ ? 'checkUsernameCallbacks'
2941
+ : T extends 'beforeRegister'
2942
+ ? 'beforeRegisterCallbacks'
2943
+ : T extends 'afterRegister'
2944
+ ? 'afterRegisterCallbacks'
2945
+ : T extends 'beforeSave'
2946
+ ? 'beforeSaveCallbacks'
2947
+ : T extends 'afterSave'
2948
+ ? 'afterSaveCallbacks'
2949
+ : T extends 'beforeLogin'
2950
+ ? 'beforeLoginCallbacks'
2951
+ : T extends 'afterLogin'
2952
+ ? 'afterLoginCallbacks'
2953
+ : T extends 'beforeLogout'
2954
+ ? 'beforeLogoutCallbacks'
2955
+ : T extends 'afterLogout'
2956
+ ? 'afterLogoutCallbacks'
2957
+ : T extends 'beforeDelete'
2958
+ ? 'beforeDeleteCallbacks'
2959
+ : T extends 'afterDelete'
2960
+ ? 'afterDeleteCallbacks'
2961
+ : never;
1859
2962
  if (!(prop in this)) {
1860
2963
  return false;
1861
2964
  }