@nymphjs/tilmeld 1.0.0-beta.11 → 1.0.0-beta.110

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 (83) hide show
  1. package/CHANGELOG.md +498 -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 +182 -15
  7. package/dist/Group.js +564 -191
  8. package/dist/Group.js.map +1 -1
  9. package/dist/Group.test.js +86 -13
  10. package/dist/Group.test.js.map +1 -1
  11. package/dist/Tilmeld.d.ts +241 -12
  12. package/dist/Tilmeld.js +657 -257
  13. package/dist/Tilmeld.js.map +1 -1
  14. package/dist/Tilmeld.types.d.ts +5 -5
  15. package/dist/Tilmeld.types.js +1 -2
  16. package/dist/User.d.ts +396 -22
  17. package/dist/User.js +1379 -367
  18. package/dist/User.js.map +1 -1
  19. package/dist/User.test.js +133 -13
  20. package/dist/User.test.js.map +1 -1
  21. package/dist/conf/d.d.ts +306 -7
  22. package/dist/conf/d.js +1 -2
  23. package/dist/conf/defaults.d.ts +1 -1
  24. package/dist/conf/defaults.js +196 -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 +12 -10
  52. package/dist/index.js +17 -35
  53. package/dist/index.js.map +1 -1
  54. package/dist/testArtifacts.d.ts +19 -0
  55. package/dist/testArtifacts.js +920 -0
  56. package/dist/testArtifacts.js.map +1 -0
  57. package/dist/tilmeldJoiProps.d.ts +3 -3
  58. package/dist/tilmeldJoiProps.js +13 -18
  59. package/dist/tilmeldJoiProps.js.map +1 -1
  60. package/jest.config.js +11 -2
  61. package/package.json +32 -31
  62. package/src/AbleObject.ts +2 -2
  63. package/src/Group.test.ts +107 -6
  64. package/src/Group.ts +676 -224
  65. package/src/Tilmeld.ts +450 -262
  66. package/src/Tilmeld.types.ts +5 -5
  67. package/src/User.test.ts +163 -7
  68. package/src/User.ts +1621 -454
  69. package/src/conf/d.ts +83 -17
  70. package/src/conf/defaults.ts +172 -109
  71. package/src/conf/index.ts +2 -2
  72. package/src/enforceTilmeld.ts +20 -0
  73. package/src/errors/AccessControlError.ts +4 -2
  74. package/src/errors/BadDataError.ts +4 -2
  75. package/src/errors/BadEmailError.ts +4 -2
  76. package/src/errors/BadUsernameError.ts +4 -2
  77. package/src/errors/index.ts +6 -6
  78. package/src/index.ts +14 -10
  79. package/src/testArtifacts.ts +1485 -0
  80. package/src/tilmeldJoiProps.ts +8 -23
  81. package/src/type/locutus.d.ts +3 -0
  82. package/tsconfig.json +5 -3
  83. 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,9 +335,37 @@ export default class User extends AbleObject<UserData> {
256
335
  * verification, don't set this.
257
336
  */
258
337
  public $originalEmail?: string;
338
+ /**
339
+ * Used to save the current username for domain admin permissions.
340
+ */
341
+ public $originalUsername?: string;
259
342
 
260
- static async factory(guid?: string): Promise<User & UserData> {
261
- return (await super.factory(guid)) as User & UserData;
343
+ public static getFTSText(name: string, value: any) {
344
+ if (
345
+ [
346
+ 'abilities',
347
+ 'group',
348
+ 'groups',
349
+ 'inheritAbilities',
350
+ 'enabled',
351
+ 'secret',
352
+ 'emailChangeDate',
353
+ 'newEmailSecret',
354
+ 'newEmailAddress',
355
+ 'cancelEmailSecret',
356
+ 'cancelEmailAddress',
357
+ 'recoverSecret',
358
+ 'recoverSecretDate',
359
+ 'salt',
360
+ 'password',
361
+ 'passwordTemp',
362
+ 'revokeTokenDate',
363
+ 'totpSecret',
364
+ ].indexOf(name) !== -1
365
+ ) {
366
+ return null;
367
+ }
368
+ return super.getFTSText(name, value);
262
369
  }
263
370
 
264
371
  static async factoryUsername(username?: string): Promise<User & UserData> {
@@ -271,7 +378,7 @@ export default class User extends AbleObject<UserData> {
271
378
  {
272
379
  type: '&',
273
380
  ilike: ['username', username.replace(/([\\%_])/g, (s) => `\\${s}`)],
274
- }
381
+ },
275
382
  );
276
383
  if (entity != null) {
277
384
  return entity;
@@ -280,34 +387,18 @@ export default class User extends AbleObject<UserData> {
280
387
  return entity;
281
388
  }
282
389
 
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
390
  public static current(returnObjectIfNotExist: true): User & UserData;
301
391
  public static current(
302
- returnObjectIfNotExist?: false
392
+ returnObjectIfNotExist?: false,
303
393
  ): (User & UserData) | null;
304
394
  public static current(
305
- returnObjectIfNotExist?: boolean
395
+ returnObjectIfNotExist?: boolean,
306
396
  ): (User & UserData) | null {
307
- if (this.tilmeld.currentUser == null) {
397
+ const tilmeld = enforceTilmeld(this);
398
+ if (tilmeld.currentUser == null) {
308
399
  return returnObjectIfNotExist ? this.factorySync() : null;
309
400
  }
310
- return this.tilmeld.currentUser;
401
+ return tilmeld.currentUser;
311
402
  }
312
403
 
313
404
  /**
@@ -320,7 +411,8 @@ export default class User extends AbleObject<UserData> {
320
411
  recoveryType: 'username' | 'password';
321
412
  account: string;
322
413
  }): Promise<{ result: boolean; message: string }> {
323
- if (!this.tilmeld.config.pwRecovery) {
414
+ const tilmeld = enforceTilmeld(this);
415
+ if (!tilmeld.config.pwRecovery) {
324
416
  return {
325
417
  result: false,
326
418
  message: 'Account recovery is not allowed.',
@@ -330,18 +422,15 @@ export default class User extends AbleObject<UserData> {
330
422
  let user: User & UserData;
331
423
  const options: EmailOptions = {};
332
424
 
333
- if (
334
- !this.tilmeld.config.emailUsernames &&
335
- data.recoveryType === 'username'
336
- ) {
425
+ if (!tilmeld.config.emailUsernames && data.recoveryType === 'username') {
337
426
  // Create a username recovery email.
338
427
 
339
428
  const getUser = await this.nymph.getEntity(
340
- { class: this.tilmeld.User, skipAc: true },
429
+ { class: tilmeld.User, skipAc: true },
341
430
  {
342
431
  type: '&',
343
432
  ilike: ['email', data.account.replace(/([\\%_])/g, (s) => `\\${s}`)],
344
- }
433
+ },
345
434
  );
346
435
 
347
436
  if (getUser == null) {
@@ -364,7 +453,7 @@ export default class User extends AbleObject<UserData> {
364
453
  } else if (data.recoveryType === 'password') {
365
454
  // Create a password recovery email.
366
455
 
367
- const getUser = await this.tilmeld.User.factoryUsername(data.account);
456
+ const getUser = await tilmeld.User.factoryUsername(data.account);
368
457
 
369
458
  if (getUser.guid == null) {
370
459
  return {
@@ -391,14 +480,19 @@ export default class User extends AbleObject<UserData> {
391
480
  };
392
481
  options.locals = {
393
482
  recoverCode: getUser.recoverSecret,
394
- timeLimit: this.tilmeld.config.pwRecoveryTimeLimit,
483
+ timeLimit: tilmeld.config.pwRecoveryTimeLimit,
395
484
  };
396
485
  } else {
397
486
  return { result: false, message: 'Invalid recovery type.' };
398
487
  }
399
488
 
489
+ this.nymph.config.debugLog(
490
+ 'tilmeld',
491
+ `User account recovery (${options.template}) request for "${user.username}".`,
492
+ );
493
+
400
494
  // Send the email.
401
- if (await this.tilmeld.config.sendEmail(this.tilmeld, options, user)) {
495
+ if (await tilmeld.config.sendEmail(tilmeld, options, user)) {
402
496
  return {
403
497
  result: true,
404
498
  message:
@@ -421,22 +515,23 @@ export default class User extends AbleObject<UserData> {
421
515
  secret: string;
422
516
  password: string;
423
517
  }): Promise<{ result: boolean; message: string }> {
424
- if (!this.tilmeld.config.pwRecovery) {
518
+ const tilmeld = enforceTilmeld(this);
519
+ if (!tilmeld.config.pwRecovery) {
425
520
  return {
426
521
  result: false,
427
522
  message: 'Account recovery is not allowed.',
428
523
  };
429
524
  }
430
525
 
431
- const user = await this.tilmeld.User.factoryUsername(data.username);
526
+ const user = await tilmeld.User.factoryUsername(data.username);
432
527
 
433
528
  if (
434
529
  user.guid == null ||
435
530
  user.recoverSecret == null ||
436
531
  data.secret !== user.recoverSecret ||
437
532
  strtotime(
438
- '+' + this.tilmeld.config.pwRecoveryTimeLimit,
439
- Math.floor((user.recoverSecretDate ?? 0) / 1000)
533
+ '+' + tilmeld.config.pwRecoveryTimeLimit,
534
+ Math.floor((user.recoverSecretDate ?? 0) / 1000),
440
535
  ) *
441
536
  1000 <
442
537
  Date.now()
@@ -451,6 +546,11 @@ export default class User extends AbleObject<UserData> {
451
546
  return { result: false, message: 'Password cannot be empty.' };
452
547
  }
453
548
 
549
+ this.nymph.config.debugLog(
550
+ 'tilmeld',
551
+ `User account recovery for "${user.username}".`,
552
+ );
553
+
454
554
  user.$password(data.password);
455
555
  delete user.recoverSecret;
456
556
  delete user.recoverSecretDate;
@@ -466,27 +566,31 @@ export default class User extends AbleObject<UserData> {
466
566
  }
467
567
 
468
568
  public static getClientConfig() {
569
+ const tilmeld = enforceTilmeld(this);
469
570
  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,
571
+ regFields: tilmeld.config.regFields,
572
+ userFields: tilmeld.config.userFields,
573
+ emailUsernames: tilmeld.config.emailUsernames,
574
+ domainSupport: tilmeld.config.domainSupport,
575
+ allowRegistration: tilmeld.config.allowRegistration,
576
+ allowUsernameChange: tilmeld.config.allowUsernameChange,
577
+ pwRecovery: tilmeld.config.pwRecovery,
578
+ verifyEmail: tilmeld.config.verifyEmail,
579
+ unverifiedAccess: tilmeld.config.unverifiedAccess,
478
580
  };
479
581
  }
480
582
 
481
583
  public static async loginUser(data: {
482
584
  username: string;
483
585
  password: string;
586
+ code?: string;
484
587
  additionalData?: { [k: string]: any };
485
588
  }) {
589
+ const tilmeld = enforceTilmeld(this);
486
590
  if (!('username' in data) || !data.username.length) {
487
591
  return { result: false, message: 'Incorrect login/password.' };
488
592
  }
489
- const user = await this.tilmeld.User.factoryUsername(data.username);
593
+ const user = await tilmeld.User.factoryUsername(data.username);
490
594
  const result: { result: boolean; message: string; user?: User & UserData } =
491
595
  await user.$login(data);
492
596
  if (result.result) {
@@ -496,23 +600,150 @@ export default class User extends AbleObject<UserData> {
496
600
  return result;
497
601
  }
498
602
 
603
+ /**
604
+ * Get the users in a domain. This is only accessible to the domain's admins.
605
+ */
606
+ public static async getDomainUsers(
607
+ domain: string,
608
+ options?: {
609
+ limit?: number;
610
+ offset?: number;
611
+ sort?: string;
612
+ reverse?: boolean;
613
+ },
614
+ ) {
615
+ const tilmeld = enforceTilmeld(this);
616
+
617
+ if (!tilmeld.config.domainSupport) {
618
+ throw new AccessControlError('Domain support is not enabled.');
619
+ }
620
+
621
+ if (!domain) {
622
+ throw new BadDataError('Domain is required.');
623
+ }
624
+
625
+ domain = domain.toLowerCase();
626
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
627
+ throw new BadDataError(tilmeld.config.validDomainRegexNotice);
628
+ }
629
+
630
+ if (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)) {
631
+ throw new AccessControlError(
632
+ 'You do not have the authority to manage this domain.',
633
+ );
634
+ }
635
+
636
+ return await this.nymph.getEntities(
637
+ {
638
+ class: this.nymph.getEntityClass(User),
639
+ ...(options?.limit != null
640
+ ? {
641
+ limit: options.limit,
642
+ }
643
+ : {}),
644
+ ...(options?.offset != null
645
+ ? {
646
+ offset: options.offset,
647
+ }
648
+ : {}),
649
+ ...(options?.sort != null
650
+ ? {
651
+ sort: options.sort,
652
+ }
653
+ : {}),
654
+ ...(options?.reverse != null
655
+ ? {
656
+ reverse: options.reverse,
657
+ }
658
+ : {}),
659
+ },
660
+ {
661
+ type: '&',
662
+ like: ['username', `%@${domain.replace(/([\\%_])/g, (s) => `\\${s}`)}`],
663
+ },
664
+ );
665
+ }
666
+
667
+ constructor() {
668
+ super();
669
+
670
+ // Defaults.
671
+ this.$data.enabled = true;
672
+ this.$data.abilities = [];
673
+ this.$data.groups = [];
674
+ this.$data.inheritAbilities = true;
675
+ this.$updateDataProtection();
676
+ }
677
+
678
+ $setNymph(nymph: Nymph) {
679
+ this.$nymph = nymph;
680
+ if (!this.$asleep()) {
681
+ if (this.$data.group && this.$data.group.$nymph !== nymph) {
682
+ this.$data.group.$setNymph(nymph);
683
+ }
684
+ for (const group of this.$data.groups ?? []) {
685
+ if (group && group.$nymph !== nymph) {
686
+ group.$setNymph(nymph);
687
+ }
688
+ }
689
+ }
690
+ }
691
+
692
+ async $getUniques(): Promise<string[]> {
693
+ const tilmeld = enforceTilmeld(this);
694
+ const uniques = [`u:${this.$data.username}`];
695
+ if (
696
+ !tilmeld.config.emailUsernames &&
697
+ tilmeld.config.userFields.includes('email')
698
+ ) {
699
+ uniques.push(`e:${this.$data.email}`);
700
+
701
+ if (this.$data.newEmailAddress != null) {
702
+ uniques.push(`e:${this.$data.newEmailAddress}`);
703
+ }
704
+ }
705
+ return uniques;
706
+ }
707
+
499
708
  public async $login(data: {
500
709
  username: string;
501
710
  password: string;
711
+ code?: string;
502
712
  additionalData?: { [k: string]: any };
503
713
  }) {
504
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
714
+ const tilmeld = enforceTilmeld(this);
505
715
  if (this.guid == null) {
716
+ await tilmeld.config.failedLoginAttempt(null, data);
717
+ return { result: false, message: 'Incorrect login/password.' };
718
+ }
719
+ if (!this.$checkPassword(data.password)) {
720
+ await tilmeld.config.failedLoginAttempt(this, data);
506
721
  return { result: false, message: 'Incorrect login/password.' };
507
722
  }
508
723
  if (!this.$data.enabled) {
509
- return { result: false, message: 'This user is disabled.' };
724
+ return { result: false, message: 'Your account is disabled.' };
510
725
  }
511
- if (this.$gatekeeper()) {
726
+ if (await this.$gatekeeper()) {
512
727
  return { result: true, message: 'You are already logged in.' };
513
728
  }
514
- if (!this.$checkPassword(data.password)) {
515
- return { result: false, message: 'Incorrect login/password.' };
729
+
730
+ if (this.$data.totpSecret != null) {
731
+ if (
732
+ data.code == null ||
733
+ typeof data.code !== 'string' ||
734
+ data.code.length !== 6
735
+ ) {
736
+ return {
737
+ result: false,
738
+ needTOTP: true,
739
+ message: 'You need to provide a 2FA code.',
740
+ };
741
+ }
742
+
743
+ if (!this.$checkTOTPCode(data.code)) {
744
+ await tilmeld.config.failedLoginAttempt(this, data);
745
+ return { result: false, message: 'Incorrect 2FA code.' };
746
+ }
516
747
  }
517
748
 
518
749
  try {
@@ -529,8 +760,90 @@ export default class User extends AbleObject<UserData> {
529
760
  };
530
761
  }
531
762
 
763
+ this.$nymph.config.debugLog(
764
+ 'tilmeld',
765
+ `User login for "${this.$data.username}".`,
766
+ );
767
+
768
+ // Authentication was successful, attempt to login.
769
+ if (!(await tilmeld.login(this, true))) {
770
+ return { result: false, message: 'Incorrect login/password.' };
771
+ }
772
+
773
+ for (let callback of (this.constructor as typeof User)
774
+ .afterLoginCallbacks) {
775
+ if (callback) {
776
+ await callback(this);
777
+ }
778
+ }
779
+
780
+ // Login was successful.
781
+ return { result: true, message: 'You are logged in.' };
782
+ }
783
+
784
+ public async $switchUser(data?: { additionalData?: { [k: string]: any } }) {
785
+ const tilmeld = enforceTilmeld(this);
786
+ if (this.guid == null) {
787
+ return { result: false, message: 'Incorrect login/password.' };
788
+ }
789
+
790
+ let user = tilmeld.User.current();
791
+
792
+ if (!user || user.guid == null) {
793
+ return { result: false, message: "You're not logged in." };
794
+ }
795
+
796
+ const isCurrentUser = user != null && user.$is(this);
797
+
798
+ if (isCurrentUser) {
799
+ return { result: false, message: "You can't switch to yourself." };
800
+ }
801
+
802
+ const toAbilities = this.$data.abilities || [];
803
+ const fromAbilities = user.abilities || [];
804
+
805
+ if (
806
+ !fromAbilities.includes('tilmeld/switch') &&
807
+ !fromAbilities.includes('system/admin')
808
+ ) {
809
+ return {
810
+ result: false,
811
+ message: "You don't have permission to switch users.",
812
+ };
813
+ }
814
+
815
+ if (
816
+ toAbilities.includes('system/admin') ||
817
+ toAbilities.includes('tilmeld/admin')
818
+ ) {
819
+ return { result: false, message: "You can't switch to an admin." };
820
+ }
821
+
822
+ try {
823
+ for (let callback of (this.constructor as typeof User)
824
+ .beforeLoginCallbacks) {
825
+ if (callback) {
826
+ await callback(this, {
827
+ username: this.$data.username || '',
828
+ password: '',
829
+ ...(data || {}),
830
+ });
831
+ }
832
+ }
833
+ } catch (e: any) {
834
+ return {
835
+ result: false,
836
+ message: e.message,
837
+ };
838
+ }
839
+
840
+ this.$nymph.config.debugLog(
841
+ 'tilmeld',
842
+ `User login switch for "${user.username}" to "${this.$data.username}".`,
843
+ );
844
+
532
845
  // Authentication was successful, attempt to login.
533
- if (!tilmeld.login(this, true)) {
846
+ if (!(await tilmeld.loginSwitch(this, true))) {
534
847
  return { result: false, message: 'Incorrect login/password.' };
535
848
  }
536
849
 
@@ -550,7 +863,7 @@ export default class User extends AbleObject<UserData> {
550
863
  * @returns An object with a boolean 'result' entry and a 'message' entry.
551
864
  */
552
865
  public async $logout() {
553
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
866
+ const tilmeld = enforceTilmeld(this);
554
867
 
555
868
  try {
556
869
  for (let callback of (this.constructor as typeof User)
@@ -566,7 +879,12 @@ export default class User extends AbleObject<UserData> {
566
879
  };
567
880
  }
568
881
 
569
- tilmeld.logout();
882
+ this.$nymph.config.debugLog(
883
+ 'tilmeld',
884
+ `User logout for "${this.$data.username}".`,
885
+ );
886
+
887
+ await tilmeld.logout();
570
888
 
571
889
  for (let callback of (this.constructor as typeof User)
572
890
  .afterLogoutCallbacks) {
@@ -587,89 +905,176 @@ export default class User extends AbleObject<UserData> {
587
905
  }
588
906
  return (
589
907
  'https://secure.gravatar.com/avatar/' +
590
- md5(this.$data.email.trim().toLowerCase()).toString() +
908
+ crypto
909
+ .createHash('sha256')
910
+ .update(this.$data.email.trim().toLowerCase())
911
+ .digest('hex')
912
+ .toLowerCase() +
591
913
  '?d=identicon&s=40'
592
914
  );
593
915
  }
594
916
 
917
+ public $getGid() {
918
+ if (this.$data.group) {
919
+ return this.$data.group.guid;
920
+ }
921
+ return null;
922
+ }
923
+
924
+ public $getGids() {
925
+ if (this.$data.groups) {
926
+ return this.$data.groups
927
+ .map((group) => group.guid)
928
+ .filter((guid) => guid != null) as string[];
929
+ }
930
+ return null;
931
+ }
932
+
595
933
  /**
596
934
  * Get the user's group descendants.
597
935
  */
598
- public async $getDescendantGroups(): Promise<(Group & GroupData)[]> {
936
+ public async $getDescendantGroups(): Promise<(GroupClass & GroupData)[]> {
599
937
  if (this.$descendantGroups == null) {
600
938
  this.$descendantGroups = [];
601
939
  if (this.$data.group != null) {
940
+ await this.$data.group.$wake();
602
941
  this.$descendantGroups = await this.$data.group.$getDescendants();
603
942
  }
943
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
604
944
  for (let curGroup of this.$data.groups ?? []) {
605
945
  this.$descendantGroups = this.$descendantGroups?.concat(
606
- await curGroup.$getDescendants()
946
+ await curGroup.$getDescendants(),
607
947
  );
608
948
  }
609
949
  }
610
950
  return this.$descendantGroups;
611
951
  }
612
952
 
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
- );
953
+ public $jsonAcceptData(input: EntityJson, allowConflict = false) {
954
+ const tilmeld = enforceTilmeld(this);
955
+ this.$check();
956
+
957
+ if ('abilities' in input.data) {
958
+ this.$checkIncomingAbilities(input.data.abilities);
959
+ }
960
+
961
+ // If the user is a new domain user, the data protection needs to be updated
962
+ // before the data is accepted, or else some things won't be accepted
963
+ // correctly (enabled, passwordTemp).
964
+ let reloadDataProtection = false;
965
+ if (tilmeld.config.domainSupport) {
966
+ let [_username, domain] = splitn(input.data.username ?? '', '@', 2);
967
+ // Lowercase the domain part.
968
+ if (domain) {
969
+ domain = domain.toLowerCase();
626
970
  }
971
+ const user = tilmeld.User.current();
972
+ const isCurrentUser = user != null && user.$is(this);
973
+ // While loading, accessing abilities on the current user will infinitely
974
+ // loop, but if this is the current user, we can use its abilities.
975
+ const abilities =
976
+ (isCurrentUser || user == null
977
+ ? this.$data.abilities
978
+ : user?.abilities) ?? [];
979
+ const isNewUser = input.guid == null;
980
+ const isDomainUser =
981
+ !isCurrentUser &&
982
+ user != null &&
983
+ domain &&
984
+ abilities.includes(`tilmeld/domain/${domain}/admin`);
985
+
986
+ reloadDataProtection = !!(isNewUser && isDomainUser);
987
+ }
988
+
989
+ super.$jsonAcceptData(input, allowConflict);
990
+
991
+ if (reloadDataProtection) {
992
+ // Load it again after data protection is run.
993
+ this.$updateDataProtection();
994
+ super.$jsonAcceptData(input, allowConflict);
627
995
  }
628
- return this.$descendantGroups;
629
996
  }
630
997
 
631
- public $jsonAcceptData(input: EntityJson, allowConflict = false) {
632
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
633
- this.$referenceWake();
998
+ public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
999
+ this.$check();
1000
+
1001
+ if ('abilities' in patch.set) {
1002
+ this.$checkIncomingAbilities(patch.set.abilities);
1003
+ }
1004
+
1005
+ super.$jsonAcceptPatch(patch, allowConflict);
1006
+ }
1007
+
1008
+ private $checkIncomingAbilities(incoming: any) {
1009
+ const tilmeld = enforceTilmeld(this);
634
1010
 
635
1011
  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')
1012
+ !Array.isArray(incoming) ||
1013
+ incoming.find((ability) => typeof ability !== 'string')
1014
+ ) {
1015
+ throw new BadDataError('Abilities must be an array of strings.');
1016
+ }
1017
+
1018
+ if (
1019
+ incoming.includes('system/admin') !==
1020
+ this.$data.abilities?.includes('system/admin') &&
1021
+ (!tilmeld.currentUser ||
1022
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
641
1023
  ) {
642
1024
  throw new BadDataError(
643
- "You don't have the authority to make this user a system admin."
1025
+ "You don't have the authority to grant or revoke system/admin.",
644
1026
  );
645
1027
  }
646
1028
 
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();
1029
+ if (
1030
+ incoming.includes('tilmeld/admin') !==
1031
+ this.$data.abilities?.includes('tilmeld/admin') &&
1032
+ (!tilmeld.currentUser ||
1033
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
1034
+ ) {
1035
+ throw new BadDataError(
1036
+ "You don't have the authority to grant or revoke tilmeld/admin.",
1037
+ );
1038
+ }
654
1039
 
655
1040
  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')
1041
+ incoming.includes('tilmeld/switch') !==
1042
+ this.$data.abilities?.includes('tilmeld/switch') &&
1043
+ (!tilmeld.currentUser ||
1044
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
661
1045
  ) {
662
1046
  throw new BadDataError(
663
- "You don't have the authority to make this user a system admin."
1047
+ "You don't have the authority to grant or revoke tilmeld/switch.",
664
1048
  );
665
1049
  }
666
1050
 
667
- this.$originalEmail = this.$data.email;
668
- super.$jsonAcceptPatch(patch, allowConflict);
1051
+ if (
1052
+ xor(
1053
+ incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
1054
+ this.$data.abilities?.filter((ability: string) =>
1055
+ ability.startsWith('tilmeld/'),
1056
+ ) ?? [],
1057
+ ).length &&
1058
+ (!tilmeld.currentUser ||
1059
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
1060
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
1061
+ ) {
1062
+ throw new BadDataError(
1063
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
1064
+ );
1065
+ }
669
1066
  }
670
1067
 
671
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
672
- super.$putData(data, sdata);
1068
+ public $putData(
1069
+ data: EntityData,
1070
+ sdata?: SerializedEntityData,
1071
+ source?: 'server',
1072
+ ) {
1073
+ super.$putData(data, sdata, source);
1074
+ if (source === 'server') {
1075
+ this.$originalEmail = this.$data.email;
1076
+ this.$originalUsername = this.$data.username;
1077
+ }
673
1078
  this.$updateDataProtection();
674
1079
  }
675
1080
 
@@ -679,8 +1084,15 @@ export default class User extends AbleObject<UserData> {
679
1084
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
680
1085
  */
681
1086
  public $updateDataProtection(givenUser?: User & UserData) {
682
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1087
+ const tilmeld = enforceTilmeld(this);
683
1088
  let user = givenUser ?? tilmeld.User.current();
1089
+ let [_username, domain] = tilmeld.config.domainSupport
1090
+ ? splitn(this.$originalUsername ?? this.$data.username ?? '', '@', 2)
1091
+ : [this.$originalUsername ?? this.$data.username, null];
1092
+ // Lowercase the domain part.
1093
+ if (domain) {
1094
+ domain = domain.toLowerCase();
1095
+ }
684
1096
 
685
1097
  this.$clientEnabledMethods = [...User.DEFAULT_CLIENT_ENABLED_METHODS];
686
1098
  this.$privateData = [...User.DEFAULT_PRIVATE_DATA];
@@ -698,53 +1110,165 @@ export default class User extends AbleObject<UserData> {
698
1110
  ? this.$data.abilities
699
1111
  : user?.abilities) ?? [];
700
1112
  const isNewUser = this.guid == null;
1113
+ const isDomainUser =
1114
+ tilmeld.config.domainSupport &&
1115
+ !isCurrentUser &&
1116
+ user != null &&
1117
+ domain &&
1118
+ abilities.includes(`tilmeld/domain/${domain}/admin`);
701
1119
 
702
1120
  if (isCurrentUser) {
703
1121
  // Users can check to see what abilities they have.
704
1122
  this.$clientEnabledMethods.push('$gatekeeper');
705
1123
  this.$clientEnabledMethods.push('$changePassword');
1124
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
706
1125
  this.$clientEnabledMethods.push('$logout');
707
1126
  this.$clientEnabledMethods.push('$sendEmailVerification');
1127
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1128
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
1129
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
1130
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
1131
+ }
1132
+
1133
+ if (isDomainUser) {
1134
+ // Domain admins can check to see what abilities their users have.
1135
+ this.$clientEnabledMethods.push('$gatekeeper');
1136
+ this.$clientEnabledMethods.push('$changePassword');
1137
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
1138
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1139
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
1140
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
1141
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
708
1142
  }
709
1143
 
710
1144
  if (
711
1145
  user != null &&
712
- (abilities.indexOf('tilmeld/admin') !== -1 ||
713
- abilities.indexOf('system/admin') !== -1)
1146
+ (abilities.includes('tilmeld/admin') ||
1147
+ abilities.includes('system/admin'))
714
1148
  ) {
715
1149
  // Users who can edit other users can see most of their data.
716
- this.$privateData = ['password', 'salt'];
1150
+ this.$privateData = ['password', 'salt', 'totpSecret'];
1151
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
1152
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
717
1153
  this.$allowlistData = undefined;
1154
+ } else if (isDomainUser) {
1155
+ // Domain admins can see their users' data, and edit some of it.
1156
+ if (tilmeld.config.allowUsernameChange || isNewUser) {
1157
+ this.$allowlistData.push('username');
1158
+ }
1159
+ this.$allowlistData.push('avatar');
1160
+ if (tilmeld.config.userFields.includes('name')) {
1161
+ this.$allowlistData.push('nameFirst');
1162
+ this.$allowlistData.push('nameMiddle');
1163
+ this.$allowlistData.push('nameLast');
1164
+ this.$allowlistData.push('name');
1165
+ }
1166
+ if (tilmeld.config.userFields.includes('email')) {
1167
+ this.$allowlistData.push('email');
1168
+ }
1169
+ if (tilmeld.config.userFields.includes('phone')) {
1170
+ this.$allowlistData.push('phone');
1171
+ }
1172
+ this.$allowlistData.push('enabled');
1173
+ this.$allowlistData.push('passwordTemp');
1174
+ this.$privateData = [
1175
+ // The things commented are there to show what is _not_ private.
1176
+ // 'abilities',
1177
+ // 'username',
1178
+ // 'nameFirst',
1179
+ // 'nameMiddle',
1180
+ // 'nameLast',
1181
+ // 'name',
1182
+ // 'email',
1183
+ // 'avatar',
1184
+ // 'phone',
1185
+ // 'group',
1186
+ // 'groups',
1187
+ // 'inheritAbilities',
1188
+ // 'enabled',
1189
+ 'secret',
1190
+ 'emailChangeDate',
1191
+ 'newEmailSecret',
1192
+ // 'newEmailAddress',
1193
+ 'cancelEmailSecret',
1194
+ 'cancelEmailAddress',
1195
+ 'recoverSecret',
1196
+ 'recoverSecretDate',
1197
+ 'salt',
1198
+ 'password',
1199
+ // 'passwordTemp',
1200
+ // 'revokeTokenDate',
1201
+ 'totpSecret',
1202
+ ];
718
1203
  } else if (isCurrentUser || isNewUser) {
719
1204
  // Users can see their own data, and edit some of it.
720
1205
  if (tilmeld.config.allowUsernameChange || isNewUser) {
721
1206
  this.$allowlistData.push('username');
722
1207
  }
723
1208
  this.$allowlistData.push('avatar');
724
- if (tilmeld.config.userFields.indexOf('name') !== -1) {
1209
+ if (tilmeld.config.userFields.includes('name')) {
725
1210
  this.$allowlistData.push('nameFirst');
726
1211
  this.$allowlistData.push('nameMiddle');
727
1212
  this.$allowlistData.push('nameLast');
728
1213
  this.$allowlistData.push('name');
729
1214
  }
730
- if (tilmeld.config.userFields.indexOf('email') !== -1) {
1215
+ if (tilmeld.config.userFields.includes('email')) {
731
1216
  this.$allowlistData.push('email');
732
1217
  }
733
- if (tilmeld.config.userFields.indexOf('phone') !== -1) {
1218
+ if (tilmeld.config.userFields.includes('phone')) {
734
1219
  this.$allowlistData.push('phone');
735
1220
  }
736
1221
  this.$privateData = [
1222
+ // The things commented are there to show what is _not_ private.
1223
+ // 'abilities',
1224
+ // 'username',
1225
+ // 'nameFirst',
1226
+ // 'nameMiddle',
1227
+ // 'nameLast',
1228
+ // 'name',
1229
+ // 'email',
1230
+ // 'avatar',
1231
+ // 'phone',
1232
+ // 'group',
1233
+ // 'groups',
1234
+ // 'inheritAbilities',
1235
+ 'enabled',
737
1236
  'secret',
1237
+ 'emailChangeDate',
738
1238
  'newEmailSecret',
739
- 'cancelEmailAddress',
1239
+ // 'newEmailAddress',
740
1240
  'cancelEmailSecret',
741
- 'emailChangeDate',
1241
+ 'cancelEmailAddress',
742
1242
  'recoverSecret',
743
1243
  'recoverSecretDate',
744
- 'password',
745
1244
  'salt',
1245
+ 'password',
1246
+ 'passwordTemp',
1247
+ // 'revokeTokenDate',
1248
+ 'totpSecret',
746
1249
  ];
747
1250
  }
1251
+
1252
+ if (
1253
+ user != null &&
1254
+ !isCurrentUser &&
1255
+ (abilities.includes('tilmeld/switch') ||
1256
+ abilities.includes('system/admin'))
1257
+ ) {
1258
+ // Users with this ability can switch to other users.
1259
+ this.$clientEnabledMethods.push('$switchUser');
1260
+ }
1261
+ }
1262
+
1263
+ public $updateGroupDataProtection() {
1264
+ if (this.$data.group != null && !this.$data.group.$asleep()) {
1265
+ this.$data.group.$updateDataProtection();
1266
+ }
1267
+ for (let group of this.$data.groups ?? []) {
1268
+ if (!group.$asleep()) {
1269
+ group.$updateDataProtection();
1270
+ }
1271
+ }
748
1272
  }
749
1273
 
750
1274
  /**
@@ -761,35 +1285,43 @@ export default class User extends AbleObject<UserData> {
761
1285
  * @param ability The ability.
762
1286
  * @returns True or false.
763
1287
  */
764
- public $gatekeeper(ability?: string): boolean {
765
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1288
+ public async $gatekeeper(ability?: string): Promise<boolean> {
1289
+ await this.$wake();
1290
+ const tilmeld = enforceTilmeld(this);
766
1291
  if (ability == null) {
767
1292
  return tilmeld.User.current()?.$is(this) ?? false;
768
1293
  }
769
1294
  // Check the cache to see if we've already checked this user.
770
1295
  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
- );
1296
+ this.$gatekeeperCache = await this.$getGatekeeperCache();
789
1297
  }
790
1298
  return (
791
1299
  (ability in this.$gatekeeperCache && !!this.$gatekeeperCache[ability]) ||
792
- !!this.$gatekeeperCache['system/admin']
1300
+ !!this.$data.abilities?.includes('system/admin')
1301
+ );
1302
+ }
1303
+
1304
+ /**
1305
+ * Build a gatekeeper cache object.
1306
+ */
1307
+ public async $getGatekeeperCache() {
1308
+ await this.$wake();
1309
+ let abilities = this.$data.abilities ?? [];
1310
+ if (this.$data.inheritAbilities) {
1311
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1312
+ for (let curGroup of this.$data.groups ?? []) {
1313
+ if (curGroup.enabled) {
1314
+ abilities = abilities.concat(curGroup.abilities ?? []);
1315
+ }
1316
+ }
1317
+ await this.$data.group?.$wake();
1318
+ const group = this.$data.group;
1319
+ if (group != null && group.cdate != null && group.enabled) {
1320
+ abilities = abilities.concat(group.abilities ?? []);
1321
+ }
1322
+ }
1323
+ return Object.fromEntries(
1324
+ abilities.map((ability) => [ability, true as true]),
793
1325
  );
794
1326
  }
795
1327
 
@@ -806,7 +1338,7 @@ export default class User extends AbleObject<UserData> {
806
1338
  * @returns True on success, false on failure.
807
1339
  */
808
1340
  public async $sendEmailVerification() {
809
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1341
+ const tilmeld = enforceTilmeld(this);
810
1342
  if (this.guid == null) {
811
1343
  return false;
812
1344
  }
@@ -818,7 +1350,7 @@ export default class User extends AbleObject<UserData> {
818
1350
 
819
1351
  if (this.$data.secret != null) {
820
1352
  const link = `${verifyUrl}?action=verify&id=${encodeURIComponent(
821
- this.guid
1353
+ this.guid,
822
1354
  )}&secret=${encodeURIComponent(this.$data.secret)}`;
823
1355
  success =
824
1356
  success &&
@@ -836,13 +1368,13 @@ export default class User extends AbleObject<UserData> {
836
1368
  verifyLink: link,
837
1369
  },
838
1370
  },
839
- this
1371
+ this,
840
1372
  ));
841
1373
  }
842
1374
 
843
1375
  if (this.$data.newEmailSecret != null) {
844
1376
  const link = `${verifyUrl}?action=verifychange&id=${encodeURIComponent(
845
- this.guid
1377
+ this.guid,
846
1378
  )}&secret=${encodeURIComponent(this.$data.newEmailSecret)}`;
847
1379
  success =
848
1380
  success &&
@@ -862,13 +1394,13 @@ export default class User extends AbleObject<UserData> {
862
1394
  newEmail: this.$data.newEmailAddress,
863
1395
  },
864
1396
  },
865
- this
1397
+ this,
866
1398
  ));
867
1399
  }
868
1400
 
869
1401
  if (this.$data.cancelEmailSecret != null) {
870
1402
  const link = `${verifyUrl}?action=cancelchange&id=${encodeURIComponent(
871
- this.guid
1403
+ this.guid,
872
1404
  )}&secret=${encodeURIComponent(this.$data.cancelEmailSecret)}`;
873
1405
  success =
874
1406
  success &&
@@ -888,7 +1420,7 @@ export default class User extends AbleObject<UserData> {
888
1420
  newEmail: this.$data.newEmailAddress ?? this.$data.email,
889
1421
  },
890
1422
  },
891
- this
1423
+ this,
892
1424
  ));
893
1425
  }
894
1426
 
@@ -901,13 +1433,16 @@ export default class User extends AbleObject<UserData> {
901
1433
  * @param group The group.
902
1434
  * @returns True if the user is already in the group. The resulting array of groups if the user was not.
903
1435
  */
904
- public $addGroup(group: Group & GroupData) {
905
- if (this.$data.groups == null) {
906
- this.$data.groups = [];
1436
+ public async $addGroup(group: GroupClass & GroupData) {
1437
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1438
+ let groups = this.$data.groups;
1439
+ if (groups == null) {
1440
+ groups = [];
907
1441
  }
908
- if (!group.$inArray(this.$data.groups)) {
909
- this.$data.groups.push(group);
910
- return this.$data.groups;
1442
+ if (!group.$inArray(groups)) {
1443
+ groups.push(group);
1444
+ this.$data.groups = groups;
1445
+ return groups;
911
1446
  }
912
1447
  return true;
913
1448
  }
@@ -919,17 +1454,23 @@ export default class User extends AbleObject<UserData> {
919
1454
  * @returns True if the passwords match, otherwise false.
920
1455
  */
921
1456
  public $checkPassword(password: string) {
922
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1457
+ const tilmeld = enforceTilmeld(this);
923
1458
  switch (tilmeld.config.pwMethod) {
924
1459
  case 'plain':
925
1460
  return this.$data.password === password;
926
1461
  case 'digest':
927
- return this.$data.password === sha256(password).toString(Base64);
1462
+ return (
1463
+ this.$data.password ===
1464
+ crypto.createHash('sha256').update(password).digest('base64')
1465
+ );
928
1466
  case 'salt':
929
1467
  default:
930
1468
  return (
931
1469
  this.$data.password ===
932
- sha256(password + this.$data.salt).toString(Base64)
1470
+ crypto
1471
+ .createHash('sha256')
1472
+ .update(password + this.$data.salt)
1473
+ .digest('base64')
933
1474
  );
934
1475
  }
935
1476
  }
@@ -940,10 +1481,12 @@ export default class User extends AbleObject<UserData> {
940
1481
  * @param group The group.
941
1482
  * @returns True if the user wasn't in the group. The resulting array of groups if the user was.
942
1483
  */
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) {
1484
+ public async $delGroup(group: GroupClass & GroupData) {
1485
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1486
+ let groups = this.$data.groups || [];
1487
+ if (this.$data.groups != null && group.$inArray(groups)) {
1488
+ const newGroups: (GroupClass & GroupData)[] = [];
1489
+ for (let curGroup of groups) {
947
1490
  if (!group.$is(curGroup)) {
948
1491
  newGroups.push(curGroup);
949
1492
  }
@@ -960,7 +1503,7 @@ export default class User extends AbleObject<UserData> {
960
1503
  * @param group The group, or the group's GUID.
961
1504
  * @returns True or false.
962
1505
  */
963
- public $inGroup(group: (Group & GroupData) | string) {
1506
+ public async $inGroup(group: (GroupClass & GroupData) | string) {
964
1507
  if (typeof group === 'string') {
965
1508
  if (this.$data.group?.guid === group) {
966
1509
  return true;
@@ -983,16 +1526,19 @@ export default class User extends AbleObject<UserData> {
983
1526
  * @param group The group, or the group's GUID.
984
1527
  * @returns True or false.
985
1528
  */
986
- public $isDescendant(group: (Group & GroupData) | string) {
1529
+ public async $isDescendant(group: (GroupClass & GroupData) | string) {
987
1530
  // Check to see if the user is in a descendant group of the given group.
1531
+ await this.$data.group?.$wake();
988
1532
  if (
1533
+ this.$data.group != null &&
989
1534
  this.$data.group?.cdate != null &&
990
- this.$data.group.$isDescendant(group)
1535
+ (await this.$data.group?.$isDescendant(group))
991
1536
  ) {
992
1537
  return true;
993
1538
  }
1539
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
994
1540
  for (let curGroup of this.$data.groups ?? []) {
995
- if (curGroup.$isDescendant(group)) {
1541
+ if (await curGroup.$isDescendant(group)) {
996
1542
  return true;
997
1543
  }
998
1544
  }
@@ -1008,15 +1554,31 @@ export default class User extends AbleObject<UserData> {
1008
1554
  public async $changePassword(data: {
1009
1555
  newPassword: string;
1010
1556
  currentPassword: string;
1557
+ revokeCurrentTokens?: boolean;
1011
1558
  }): Promise<{ result: boolean; message: string }> {
1559
+ this.$nymph.config.debugLog(
1560
+ 'tilmeld',
1561
+ `Password change request for "${this.$data.username}".`,
1562
+ );
1012
1563
  if (!('newPassword' in data) || !data.newPassword.length) {
1013
1564
  return { result: false, message: 'Please specify a password.' };
1014
1565
  }
1015
1566
  if (!this.$checkPassword(data.currentPassword ?? '')) {
1567
+ this.$nymph.config.debugLog(
1568
+ 'tilmeld',
1569
+ `Incorrect password for change request for "${this.$data.username}".`,
1570
+ );
1016
1571
  return { result: false, message: 'Incorrect password.' };
1017
1572
  }
1018
1573
  this.$data.passwordTemp = data.newPassword;
1574
+ if (data.revokeCurrentTokens) {
1575
+ this.$data.revokeTokenDate = Date.now();
1576
+ }
1019
1577
  if (await this.$save()) {
1578
+ if (data.revokeCurrentTokens) {
1579
+ const tilmeld = enforceTilmeld(this);
1580
+ await tilmeld.login(this, true);
1581
+ }
1020
1582
  return { result: true, message: 'Your password has been changed.' };
1021
1583
  } else {
1022
1584
  return { result: false, message: "Couldn't save new password." };
@@ -1030,7 +1592,7 @@ export default class User extends AbleObject<UserData> {
1030
1592
  * @returns The resulting password or hash which is stored in the entity.
1031
1593
  */
1032
1594
  public $password(password: string) {
1033
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1595
+ const tilmeld = enforceTilmeld(this);
1034
1596
  switch (tilmeld.config.pwMethod) {
1035
1597
  case 'plain':
1036
1598
  delete this.$data.salt;
@@ -1038,34 +1600,275 @@ export default class User extends AbleObject<UserData> {
1038
1600
  break;
1039
1601
  case 'digest':
1040
1602
  delete this.$data.salt;
1041
- this.$data.password = sha256(password).toString(Base64);
1603
+ this.$data.password = crypto
1604
+ .createHash('sha256')
1605
+ .update(password)
1606
+ .digest('base64');
1042
1607
  break;
1043
1608
  case 'salt':
1044
1609
  default:
1045
1610
  this.$data.salt = nanoid();
1046
- this.$data.password = sha256(password + this.$data.salt).toString(
1047
- Base64
1048
- );
1611
+ this.$data.password = crypto
1612
+ .createHash('sha256')
1613
+ .update(password + this.$data.salt)
1614
+ .digest('base64');
1049
1615
  break;
1050
1616
  }
1051
1617
  return this.$data.password;
1052
1618
  }
1053
1619
 
1620
+ /**
1621
+ * A frontend accessible method to revoke all currently issued tokens.
1622
+ *
1623
+ * @param data The input data from the client.
1624
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1625
+ */
1626
+ public async $revokeCurrentTokens(data: {
1627
+ password: string;
1628
+ }): Promise<{ result: boolean; message: string }> {
1629
+ if (!this.$checkPassword(data.password ?? '')) {
1630
+ return { result: false, message: 'Incorrect password.' };
1631
+ }
1632
+
1633
+ this.$data.revokeTokenDate = Date.now();
1634
+
1635
+ if (await this.$save()) {
1636
+ const tilmeld = enforceTilmeld(this);
1637
+ await tilmeld.login(this, true);
1638
+ return {
1639
+ result: true,
1640
+ message: 'You have logged out of all other sessions.',
1641
+ };
1642
+ } else {
1643
+ return { result: false, message: "Couldn't save revocation date." };
1644
+ }
1645
+ }
1646
+
1647
+ public async $hasTOTPSecret() {
1648
+ return this.$data.totpSecret != null;
1649
+ }
1650
+
1651
+ /**
1652
+ * Check the given code against the user's TOTP secret.
1653
+ *
1654
+ * @param code The code in question.
1655
+ * @returns True if the code is valid, otherwise false.
1656
+ */
1657
+ public $checkTOTPCode(code: string) {
1658
+ if (this.$data.totpSecret == null) {
1659
+ return false;
1660
+ }
1661
+
1662
+ const tilmeld = enforceTilmeld(this);
1663
+
1664
+ const totp = new TOTP({
1665
+ issuer: tilmeld.config.appName,
1666
+ label: this.$data.username,
1667
+ algorithm: 'SHA1',
1668
+ digits: 6,
1669
+ period: 30,
1670
+ secret: this.$data.totpSecret,
1671
+ });
1672
+
1673
+ return totp.validate({ token: code, window: 2 }) != null;
1674
+ }
1675
+
1676
+ /**
1677
+ * A frontend accessible method to generate a new TOTP secret.
1678
+ *
1679
+ * @returns An object with 'uri', 'qrcode', and 'secret'.
1680
+ */
1681
+ public async $getNewTOTPSecret() {
1682
+ const tilmeld = enforceTilmeld(this);
1683
+
1684
+ if (this.$data.totpSecret != null) {
1685
+ throw new BadDataError('You already have a 2FA secret.');
1686
+ }
1687
+
1688
+ const secret = new Secret();
1689
+
1690
+ const totp = new TOTP({
1691
+ issuer: tilmeld.config.appName,
1692
+ label: this.$data.username,
1693
+ algorithm: 'SHA1',
1694
+ digits: 6,
1695
+ period: 30,
1696
+ secret,
1697
+ });
1698
+
1699
+ const uri = totp.toString();
1700
+
1701
+ const qrcode = await toDataURL(uri);
1702
+
1703
+ return {
1704
+ uri,
1705
+ qrcode,
1706
+ secret: secret.base32,
1707
+ };
1708
+ }
1709
+
1710
+ /**
1711
+ * A frontend accessible method to save a TOTP secret into the user's account.
1712
+ *
1713
+ * @param data The input data from the client.
1714
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1715
+ */
1716
+ public async $saveTOTPSecret(data: {
1717
+ password: string;
1718
+ secret: string;
1719
+ code: string;
1720
+ }): Promise<{ result: boolean; message: string }> {
1721
+ if (!this.$checkPassword(data.password ?? '')) {
1722
+ return { result: false, message: 'Incorrect password.' };
1723
+ }
1724
+
1725
+ if (this.$data.totpSecret != null) {
1726
+ return { result: false, message: 'You already have a 2FA secret.' };
1727
+ }
1728
+
1729
+ if (
1730
+ data.secret == null ||
1731
+ typeof data.secret !== 'string' ||
1732
+ data.secret === ''
1733
+ ) {
1734
+ return { result: false, message: '2FA secret is invalid.' };
1735
+ }
1736
+
1737
+ if (
1738
+ data.code == null ||
1739
+ typeof data.code !== 'string' ||
1740
+ data.code.length !== 6
1741
+ ) {
1742
+ return { result: false, message: '2FA code is invalid.' };
1743
+ }
1744
+
1745
+ const tilmeld = enforceTilmeld(this);
1746
+
1747
+ const totp = new TOTP({
1748
+ issuer: tilmeld.config.appName,
1749
+ label: this.$data.username,
1750
+ algorithm: 'SHA1',
1751
+ digits: 6,
1752
+ period: 30,
1753
+ secret: data.secret,
1754
+ });
1755
+
1756
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1757
+ return { result: false, message: '2FA code is incorrect.' };
1758
+ }
1759
+
1760
+ this.$data.totpSecret = data.secret;
1761
+
1762
+ if (await this.$save()) {
1763
+ return { result: true, message: 'Your two factor secret is now set.' };
1764
+ } else {
1765
+ return { result: false, message: "Couldn't save two factor secret." };
1766
+ }
1767
+ }
1768
+
1769
+ /**
1770
+ * A frontend accessible method to remove the TOTP secret from the user's
1771
+ * account.
1772
+ *
1773
+ * @param data The input data from the client.
1774
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1775
+ */
1776
+ public async $removeTOTPSecret(data?: {
1777
+ password: string;
1778
+ code: string;
1779
+ }): Promise<{ result: boolean; message: string }> {
1780
+ const tilmeld = enforceTilmeld(this);
1781
+
1782
+ if (data) {
1783
+ if (!this.$checkPassword(data.password ?? '')) {
1784
+ return { result: false, message: 'Incorrect password.' };
1785
+ }
1786
+
1787
+ if (
1788
+ data.code == null ||
1789
+ typeof data.code !== 'string' ||
1790
+ data.code.length !== 6
1791
+ ) {
1792
+ return { result: false, message: '2FA code is invalid.' };
1793
+ }
1794
+
1795
+ if (this.$data.totpSecret == null) {
1796
+ return { result: false, message: "You don't have a 2FA secret." };
1797
+ }
1798
+
1799
+ const totp = new TOTP({
1800
+ issuer: tilmeld.config.appName,
1801
+ label: this.$data.username,
1802
+ algorithm: 'SHA1',
1803
+ digits: 6,
1804
+ period: 30,
1805
+ secret: this.$data.totpSecret,
1806
+ });
1807
+
1808
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1809
+ return { result: false, message: '2FA code is incorrect.' };
1810
+ }
1811
+ } else if (!tilmeld.gatekeeper('tilmeld/admin')) {
1812
+ return {
1813
+ result: false,
1814
+ message: 'You must provide your password and 2FA code.',
1815
+ };
1816
+ }
1817
+
1818
+ delete this.$data.totpSecret;
1819
+
1820
+ if (await this.$save()) {
1821
+ return {
1822
+ result: true,
1823
+ message: 'Two factor secret has been removed.',
1824
+ };
1825
+ } else {
1826
+ return { result: false, message: "Couldn't remove two factor secret." };
1827
+ }
1828
+ }
1829
+
1054
1830
  /**
1055
1831
  * Check that a username is valid.
1056
1832
  *
1057
1833
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1058
1834
  */
1059
1835
  public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1060
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1836
+ const tilmeld = enforceTilmeld(this);
1837
+
1838
+ this.$nymph.config.debugInfo(
1839
+ 'tilmeld',
1840
+ `Username check "${this.$data.username}".`,
1841
+ );
1842
+
1061
1843
  if (!tilmeld.config.emailUsernames) {
1062
- if (this.$data.username == null || !this.$data.username.length) {
1063
- return { result: false, message: 'Please specify a username.' };
1844
+ let [username, domain] = tilmeld.config.domainSupport
1845
+ ? splitn(this.$data.username ?? '', '@', 2)
1846
+ : [this.$data.username, null];
1847
+
1848
+ // Lowercase the domain part.
1849
+ if (domain) {
1850
+ domain = domain.toLowerCase();
1851
+ this.$data.username = `${username}@${domain}`;
1064
1852
  }
1853
+
1065
1854
  if (
1066
- tilmeld.config.maxUsernameLength > 0 &&
1067
- this.$data.username.length > tilmeld.config.maxUsernameLength
1855
+ this.$data.username == null ||
1856
+ !this.$data.username.length ||
1857
+ username == null ||
1858
+ !username.length
1068
1859
  ) {
1860
+ return { result: false, message: 'Please specify a username.' };
1861
+ }
1862
+ if (username.length < tilmeld.config.minUsernameLength) {
1863
+ return {
1864
+ result: false,
1865
+ message:
1866
+ 'Usernames must be at least ' +
1867
+ tilmeld.config.minUsernameLength +
1868
+ ' characters.',
1869
+ };
1870
+ }
1871
+ if (username.length > tilmeld.config.maxUsernameLength) {
1069
1872
  return {
1070
1873
  result: false,
1071
1874
  message:
@@ -1076,22 +1879,39 @@ export default class User extends AbleObject<UserData> {
1076
1879
  }
1077
1880
 
1078
1881
  if (
1079
- difference(
1080
- this.$data.username.split(''),
1081
- tilmeld.config.validChars.split('')
1082
- ).length
1882
+ difference(username.split(''), tilmeld.config.validChars.split(''))
1883
+ .length
1083
1884
  ) {
1084
1885
  return {
1085
1886
  result: false,
1086
1887
  message: tilmeld.config.validCharsNotice,
1087
1888
  };
1088
1889
  }
1089
- if (!tilmeld.config.validRegex.test(this.$data.username)) {
1890
+ if (!tilmeld.config.validRegex.test(username)) {
1090
1891
  return {
1091
1892
  result: false,
1092
1893
  message: tilmeld.config.validRegexNotice,
1093
1894
  };
1094
1895
  }
1896
+ if (domain != null) {
1897
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
1898
+ return {
1899
+ result: false,
1900
+ message: tilmeld.config.validDomainRegexNotice,
1901
+ };
1902
+ }
1903
+ if (
1904
+ !this.$skipAcWhenSaving &&
1905
+ !this.$is(tilmeld.currentUser) &&
1906
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
1907
+ ) {
1908
+ return {
1909
+ result: false,
1910
+ message:
1911
+ "You don't have the authority to manage users on this domain.",
1912
+ };
1913
+ }
1914
+ }
1095
1915
 
1096
1916
  const selector: Selector = {
1097
1917
  type: '&',
@@ -1105,12 +1925,24 @@ export default class User extends AbleObject<UserData> {
1105
1925
  }
1106
1926
  const test = await this.$nymph.getEntity(
1107
1927
  { class: tilmeld.User, skipAc: true },
1108
- selector
1928
+ selector,
1109
1929
  );
1110
1930
  if (test != null) {
1111
1931
  return { result: false, message: 'That username is taken.' };
1112
1932
  }
1113
1933
 
1934
+ for (let callback of (this.constructor as typeof User)
1935
+ .checkUsernameCallbacks) {
1936
+ if (callback) {
1937
+ const result = await callback(this, {
1938
+ username: this.$data.username,
1939
+ });
1940
+ if (!result.result) {
1941
+ return { message: 'Username is not available.', ...result };
1942
+ }
1943
+ }
1944
+ }
1945
+
1114
1946
  return {
1115
1947
  result: true,
1116
1948
  message:
@@ -1121,10 +1953,16 @@ export default class User extends AbleObject<UserData> {
1121
1953
  if (this.$data.username == null || !this.$data.username.length) {
1122
1954
  return { result: false, message: 'Please specify an email.' };
1123
1955
  }
1124
- if (
1125
- tilmeld.config.maxUsernameLength > 0 &&
1126
- this.$data.username.length > tilmeld.config.maxUsernameLength
1127
- ) {
1956
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1957
+ return {
1958
+ result: false,
1959
+ message:
1960
+ 'Emails must be at least ' +
1961
+ tilmeld.config.minUsernameLength +
1962
+ ' characters.',
1963
+ };
1964
+ }
1965
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1128
1966
  return {
1129
1967
  result: false,
1130
1968
  message:
@@ -1144,7 +1982,13 @@ export default class User extends AbleObject<UserData> {
1144
1982
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1145
1983
  */
1146
1984
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
1147
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1985
+ const tilmeld = enforceTilmeld(this);
1986
+
1987
+ this.$nymph.config.debugInfo(
1988
+ 'tilmeld',
1989
+ `Email check "${this.$data.email}".`,
1990
+ );
1991
+
1148
1992
  if (this.$data.email == null || !this.$data.email.length) {
1149
1993
  if (tilmeld.config.verifyEmail) {
1150
1994
  return { result: false, message: 'Please specify an email.' };
@@ -1167,7 +2011,7 @@ export default class User extends AbleObject<UserData> {
1167
2011
  }
1168
2012
  const test = await this.$nymph.getEntity(
1169
2013
  { class: tilmeld.User, skipAc: true },
1170
- selector
2014
+ selector,
1171
2015
  );
1172
2016
  if (test != null) {
1173
2017
  return {
@@ -1189,7 +2033,13 @@ export default class User extends AbleObject<UserData> {
1189
2033
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1190
2034
  */
1191
2035
  public async $checkPhone(): Promise<{ result: boolean; message: string }> {
1192
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
2036
+ const tilmeld = enforceTilmeld(this);
2037
+
2038
+ this.$nymph.config.debugInfo(
2039
+ 'tilmeld',
2040
+ `Phone check "${this.$data.phone}".`,
2041
+ );
2042
+
1193
2043
  if (this.$data.phone == null || !this.$data.phone.length) {
1194
2044
  return { result: false, message: 'Please specify a phone number.' };
1195
2045
  }
@@ -1211,7 +2061,7 @@ export default class User extends AbleObject<UserData> {
1211
2061
  }
1212
2062
  const test = await this.$nymph.getEntity(
1213
2063
  { class: tilmeld.User, skipAc: true },
1214
- selector
2064
+ selector,
1215
2065
  );
1216
2066
  if (test != null) {
1217
2067
  return { result: false, message: 'Phone number is in use.' };
@@ -1228,7 +2078,7 @@ export default class User extends AbleObject<UserData> {
1228
2078
  password: string;
1229
2079
  additionalData?: { [k: string]: any };
1230
2080
  }): Promise<{ result: boolean; loggedin: boolean; message: string }> {
1231
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
2081
+ let tilmeld = enforceTilmeld(this);
1232
2082
  if (!tilmeld.config.allowRegistration) {
1233
2083
  return {
1234
2084
  result: false,
@@ -1244,21 +2094,6 @@ export default class User extends AbleObject<UserData> {
1244
2094
  };
1245
2095
  }
1246
2096
 
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
2097
  if (!('password' in data) || !data.password.length) {
1263
2098
  return {
1264
2099
  result: false,
@@ -1272,7 +2107,7 @@ export default class User extends AbleObject<UserData> {
1272
2107
  }
1273
2108
 
1274
2109
  this.$password(data.password);
1275
- if (tilmeld.config.regFields.indexOf('name') !== -1) {
2110
+ if (tilmeld.config.regFields.includes('name')) {
1276
2111
  this.$data.name =
1277
2112
  this.$data.nameFirst +
1278
2113
  (this.$data.nameMiddle == null ? '' : ' ' + this.$data.nameMiddle) +
@@ -1289,75 +2124,34 @@ export default class User extends AbleObject<UserData> {
1289
2124
  const transaction = 'tilmeld-register-' + nanoid();
1290
2125
  const nymph = this.$nymph;
1291
2126
  const tnymph = await this.$nymph.startTransaction(transaction);
1292
- this.$nymph = tnymph;
1293
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2127
+ this.$setNymph(tnymph);
2128
+ tilmeld = enforceTilmeld(this);
2129
+
2130
+ let message = '';
2131
+ let loggedin = false;
1294
2132
 
1295
2133
  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],
2134
+ try {
2135
+ for (let callback of (this.constructor as typeof User)
2136
+ .beforeRegisterCallbacks) {
2137
+ if (callback) {
2138
+ await callback(this, data);
1332
2139
  }
1333
- );
1334
- if (group != null) {
1335
- this.$data.group = group;
1336
2140
  }
2141
+ } catch (e: any) {
2142
+ await tnymph.rollback(transaction);
2143
+ this.$setNymph(nymph);
2144
+ return {
2145
+ result: false,
2146
+ loggedin: false,
2147
+ message: e.message,
2148
+ };
1337
2149
  }
1338
2150
 
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) {
2151
+ if (
2152
+ tilmeld.config.userFields.includes('email') &&
2153
+ tilmeld.config.verifyEmail
2154
+ ) {
1361
2155
  // The user will be enabled after verifying their e-mail address.
1362
2156
  if (!tilmeld.config.unverifiedAccess) {
1363
2157
  this.$data.enabled = false;
@@ -1366,7 +2160,7 @@ export default class User extends AbleObject<UserData> {
1366
2160
  this.$data.enabled = true;
1367
2161
  }
1368
2162
 
1369
- // If create_admin is true and there are no other users, grant
2163
+ // If createAdmin is true and there are no other users, grant
1370
2164
  // "system/admin".
1371
2165
  let madeAdmin = false;
1372
2166
  if (tilmeld.config.createAdmin) {
@@ -1377,6 +2171,15 @@ export default class User extends AbleObject<UserData> {
1377
2171
  return: 'guid',
1378
2172
  });
1379
2173
  // Make sure it's not just null, cause that means an error.
2174
+ if (otherUsers == null) {
2175
+ await tnymph.rollback(transaction);
2176
+ this.$setNymph(nymph);
2177
+ return {
2178
+ result: false,
2179
+ loggedin: false,
2180
+ message: 'An error occurred.',
2181
+ };
2182
+ }
1380
2183
  if (!otherUsers.length) {
1381
2184
  this.$grant('system/admin');
1382
2185
  this.$data.enabled = true;
@@ -1384,7 +2187,15 @@ export default class User extends AbleObject<UserData> {
1384
2187
  }
1385
2188
  }
1386
2189
 
2190
+ this.$nymph.config.debugLog(
2191
+ 'tilmeld',
2192
+ `Registering new user "${this.$data.username}".`,
2193
+ );
1387
2194
  if (await this.$saveSkipAC()) {
2195
+ this.$nymph.config.debugLog(
2196
+ 'tilmeld',
2197
+ `New user registered "${this.$data.username}".`,
2198
+ );
1388
2199
  // Send the new user registered email.
1389
2200
  if (tilmeld.config.userRegisteredRecipient != null) {
1390
2201
  await tilmeld.config.sendEmail(
@@ -1403,33 +2214,16 @@ export default class User extends AbleObject<UserData> {
1403
2214
  userPhone: this.$data.phone,
1404
2215
  },
1405
2216
  },
1406
- this
2217
+ this,
1407
2218
  );
1408
2219
  }
1409
2220
 
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
2221
  // Finish up.
1429
2222
  if (
1430
2223
  tilmeld.config.verifyEmail &&
1431
2224
  !tilmeld.config.unverifiedAccess &&
1432
- !madeAdmin
2225
+ !madeAdmin &&
2226
+ tilmeld.config.userFields.includes('email')
1433
2227
  ) {
1434
2228
  message +=
1435
2229
  `Almost there. An email has been sent to ${this.$data.email} ` +
@@ -1437,9 +2231,10 @@ export default class User extends AbleObject<UserData> {
1437
2231
  } else if (
1438
2232
  tilmeld.config.verifyEmail &&
1439
2233
  tilmeld.config.unverifiedAccess &&
1440
- !madeAdmin
2234
+ !madeAdmin &&
2235
+ tilmeld.config.userFields.includes('email')
1441
2236
  ) {
1442
- if (!tilmeld.login(this, true)) {
2237
+ if (!(await tilmeld.login(this, true))) {
1443
2238
  throw new Error('An error occurred trying to log you in.');
1444
2239
  }
1445
2240
  message +=
@@ -1448,7 +2243,7 @@ export default class User extends AbleObject<UserData> {
1448
2243
  'registration.';
1449
2244
  loggedin = true;
1450
2245
  } else {
1451
- if (!tilmeld.login(this, true)) {
2246
+ if (!(await tilmeld.login(this, true))) {
1452
2247
  throw new Error('An error occurred trying to log you in.');
1453
2248
  }
1454
2249
  message += "You're now registered and logged in!";
@@ -1464,23 +2259,13 @@ export default class User extends AbleObject<UserData> {
1464
2259
  });
1465
2260
  }
1466
2261
  }
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
2262
  } else {
2263
+ this.$nymph.config.debugError(
2264
+ 'tilmeld',
2265
+ `Error registering new user "${this.$data.username}".`,
2266
+ );
1482
2267
  await tnymph.rollback(transaction);
1483
- this.$nymph = nymph;
2268
+ this.$setNymph(nymph);
1484
2269
  return {
1485
2270
  result: false,
1486
2271
  loggedin: false,
@@ -1488,28 +2273,100 @@ export default class User extends AbleObject<UserData> {
1488
2273
  };
1489
2274
  }
1490
2275
  } catch (e: any) {
2276
+ this.$nymph.config.debugError(
2277
+ 'tilmeld',
2278
+ `Error registering new user "${this.$data.username}": ${e}`,
2279
+ );
1491
2280
  await tnymph.rollback(transaction);
1492
- this.$nymph = nymph;
2281
+ this.$setNymph(nymph);
1493
2282
  throw e;
1494
2283
  }
2284
+
2285
+ try {
2286
+ await tnymph.commit(transaction);
2287
+ this.$setNymph(nymph);
2288
+ await this.$nymph.tilmeld?.fillSession(this);
2289
+ } catch (e: any) {
2290
+ throw e;
2291
+ }
2292
+
2293
+ return {
2294
+ result: true,
2295
+ loggedin,
2296
+ message,
2297
+ };
1495
2298
  }
1496
2299
 
1497
2300
  public async $save() {
1498
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
2301
+ let tilmeld = enforceTilmeld(this);
1499
2302
  if (this.$data.username == null || !this.$data.username.trim().length) {
1500
2303
  return false;
1501
2304
  }
1502
2305
 
1503
2306
  if (
2307
+ !this.$skipAcWhenSaving &&
1504
2308
  tilmeld.gatekeeper('tilmeld/admin') &&
1505
2309
  !tilmeld.gatekeeper('system/admin') &&
1506
- this.$gatekeeper('system/admin')
2310
+ this.$data.abilities?.includes('system/admin')
1507
2311
  ) {
1508
- throw new BadDataError(
1509
- "You don't have the authority to modify system admins."
2312
+ throw new AccessControlError(
2313
+ "You don't have the authority to modify system admins.",
1510
2314
  );
1511
2315
  }
1512
2316
 
2317
+ // Lowercase the domain part.
2318
+ if (tilmeld.config.domainSupport) {
2319
+ const [username, domain] = splitn(this.$data.username ?? '', '@', 2);
2320
+ if (domain) {
2321
+ this.$data.username = `${username}@${domain.toLowerCase()}`;
2322
+ }
2323
+ }
2324
+
2325
+ if (tilmeld.config.domainSupport) {
2326
+ const [_username, domain] = splitn(this.$data.username ?? '', '@', 2);
2327
+ const [_originalUsername, originalDomain] = splitn(
2328
+ this.$originalUsername ?? '',
2329
+ '@',
2330
+ 2,
2331
+ );
2332
+
2333
+ if (
2334
+ this.guid != null &&
2335
+ ((originalDomain == null && domain != null) ||
2336
+ (domain == null && originalDomain != null))
2337
+ ) {
2338
+ throw new AccessControlError(
2339
+ "Users can't switch to or from having a domain once created.",
2340
+ );
2341
+ }
2342
+
2343
+ if (
2344
+ !this.$skipAcWhenSaving &&
2345
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2346
+ !tilmeld.gatekeeper('system/admin')
2347
+ ) {
2348
+ if (
2349
+ domain != null &&
2350
+ !this.$is(tilmeld.currentUser) &&
2351
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
2352
+ ) {
2353
+ throw new AccessControlError(
2354
+ "You don't have the authority to modify users on this domain.",
2355
+ );
2356
+ }
2357
+ if (
2358
+ originalDomain != null &&
2359
+ originalDomain !== domain &&
2360
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
2361
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
2362
+ ) {
2363
+ throw new AccessControlError(
2364
+ "You don't have the authority to modify users on this domain.",
2365
+ );
2366
+ }
2367
+ }
2368
+ }
2369
+
1513
2370
  let sendVerification = false;
1514
2371
 
1515
2372
  // Formatting.
@@ -1528,49 +2385,88 @@ export default class User extends AbleObject<UserData> {
1528
2385
  this.$data.enabled = !!this.$data.enabled;
1529
2386
 
1530
2387
  // Clear empty values.
1531
- if (this.$data.nameFirst === '') {
2388
+ if (
2389
+ this.$data.nameFirst === '' ||
2390
+ !tilmeld.config.userFields.includes('name')
2391
+ ) {
1532
2392
  delete this.$data.nameFirst;
1533
2393
  }
1534
- if (this.$data.nameMiddle === '') {
2394
+ if (
2395
+ this.$data.nameMiddle === '' ||
2396
+ !tilmeld.config.userFields.includes('name')
2397
+ ) {
1535
2398
  delete this.$data.nameMiddle;
1536
2399
  }
1537
- if (this.$data.nameLast === '') {
2400
+ if (
2401
+ this.$data.nameLast === '' ||
2402
+ !tilmeld.config.userFields.includes('name')
2403
+ ) {
1538
2404
  delete this.$data.nameLast;
1539
2405
  }
1540
- if (this.$data.name === '') {
2406
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
1541
2407
  delete this.$data.name;
1542
2408
  }
1543
- if (this.$data.email === '') {
2409
+ if (
2410
+ this.$data.email === '' ||
2411
+ !tilmeld.config.userFields.includes('email')
2412
+ ) {
1544
2413
  delete this.$data.email;
1545
2414
  }
1546
2415
  if (this.$data.avatar === '') {
1547
2416
  delete this.$data.avatar;
1548
2417
  }
1549
- if (this.$data.phone === '') {
2418
+ if (
2419
+ this.$data.phone === '' ||
2420
+ !tilmeld.config.userFields.includes('phone')
2421
+ ) {
1550
2422
  delete this.$data.phone;
1551
2423
  }
1552
- if (this.$data.secret === '') {
2424
+ if (
2425
+ this.$data.secret === '' ||
2426
+ !tilmeld.config.userFields.includes('email')
2427
+ ) {
1553
2428
  delete this.$data.secret;
1554
2429
  }
1555
- if (this.$data.emailChangeDate === 0) {
2430
+ if (
2431
+ this.$data.emailChangeDate === 0 ||
2432
+ !tilmeld.config.userFields.includes('email')
2433
+ ) {
1556
2434
  delete this.$data.emailChangeDate;
1557
2435
  }
1558
- if (this.$data.newEmailSecret === '') {
2436
+ if (
2437
+ this.$data.newEmailSecret === '' ||
2438
+ !tilmeld.config.userFields.includes('email')
2439
+ ) {
1559
2440
  delete this.$data.newEmailSecret;
1560
2441
  }
1561
- if (this.$data.newEmailAddress === '') {
2442
+ if (
2443
+ this.$data.newEmailAddress === '' ||
2444
+ !tilmeld.config.userFields.includes('email')
2445
+ ) {
1562
2446
  delete this.$data.newEmailAddress;
1563
2447
  }
1564
- if (this.$data.cancelEmailSecret === '') {
2448
+ if (
2449
+ this.$data.cancelEmailSecret === '' ||
2450
+ !tilmeld.config.userFields.includes('email')
2451
+ ) {
1565
2452
  delete this.$data.cancelEmailSecret;
1566
2453
  }
1567
- if (this.$data.cancelEmailAddress === '') {
2454
+ if (
2455
+ this.$data.cancelEmailAddress === '' ||
2456
+ !tilmeld.config.userFields.includes('email')
2457
+ ) {
1568
2458
  delete this.$data.cancelEmailAddress;
1569
2459
  }
1570
- if (this.$data.recoverSecret === '') {
2460
+ if (
2461
+ this.$data.recoverSecret === '' ||
2462
+ !tilmeld.config.userFields.includes('email')
2463
+ ) {
1571
2464
  delete this.$data.recoverSecret;
1572
2465
  }
1573
- if (this.$data.recoverSecretDate === 0) {
2466
+ if (
2467
+ this.$data.recoverSecretDate === 0 ||
2468
+ !tilmeld.config.userFields.includes('email')
2469
+ ) {
1574
2470
  delete this.$data.recoverSecretDate;
1575
2471
  }
1576
2472
  if (this.$data.password === '') {
@@ -1588,7 +2484,10 @@ export default class User extends AbleObject<UserData> {
1588
2484
  if (!unCheck.result) {
1589
2485
  throw new BadUsernameError(unCheck.message);
1590
2486
  }
1591
- if (!tilmeld.config.emailUsernames) {
2487
+ if (
2488
+ !tilmeld.config.emailUsernames &&
2489
+ tilmeld.config.userFields.includes('email')
2490
+ ) {
1592
2491
  const emCheck = await this.$checkEmail();
1593
2492
  if (!emCheck.result) {
1594
2493
  throw new BadEmailError(emCheck.message);
@@ -1596,7 +2495,10 @@ export default class User extends AbleObject<UserData> {
1596
2495
  }
1597
2496
 
1598
2497
  // Email changes.
1599
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
2498
+ if (
2499
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2500
+ tilmeld.config.userFields.includes('email')
2501
+ ) {
1600
2502
  // The user isn't an admin, so email address changes should contain some
1601
2503
  // security measures.
1602
2504
  if (tilmeld.config.verifyEmail) {
@@ -1604,7 +2506,7 @@ export default class User extends AbleObject<UserData> {
1604
2506
  if (this.guid == null) {
1605
2507
  // If this is the first user, they'll be made an admin, so they don't
1606
2508
  // need to verify.
1607
- if ((this.$data.abilities?.indexOf('system/admin') ?? -1) === -1) {
2509
+ if (!this.$data.abilities?.includes('system/admin')) {
1608
2510
  this.$data.secret = nanoid();
1609
2511
  sendVerification = true;
1610
2512
  }
@@ -1624,10 +2526,10 @@ export default class User extends AbleObject<UserData> {
1624
2526
  new Date(
1625
2527
  strtotime(
1626
2528
  '+' + tilmeld.config.emailRateLimit,
1627
- Math.floor(this.$data.emailChangeDate / 1000)
1628
- ) * 1000
2529
+ Math.floor(this.$data.emailChangeDate / 1000),
2530
+ ) * 1000,
1629
2531
  ).toString() +
1630
- ' to change your email address again.'
2532
+ ' to change your email address again.',
1631
2533
  );
1632
2534
  } else {
1633
2535
  if (
@@ -1682,51 +2584,157 @@ export default class User extends AbleObject<UserData> {
1682
2584
  throw new BadDataError('A password is required.');
1683
2585
  }
1684
2586
 
1685
- try {
1686
- tilmeld.config.validatorUser(this);
1687
- } catch (e: any) {
1688
- throw new BadDataError(e?.message);
1689
- }
1690
-
1691
2587
  // Start transaction.
1692
- const transaction = 'tilmeld-save-' + nanoid();
2588
+ const transaction = 'tilmeld-save-user-' + (this.guid || nanoid());
1693
2589
  const nymph = this.$nymph;
1694
2590
  const tnymph = await this.$nymph.startTransaction(transaction);
1695
- this.$nymph = tnymph;
1696
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2591
+ this.$setNymph(tnymph);
2592
+ tilmeld = enforceTilmeld(this);
2593
+ const Group = tnymph.getEntityClass(GroupClass);
1697
2594
 
1698
- if (
1699
- this.$data.group != null &&
1700
- this.$data.group.user != null &&
1701
- this.$is(this.$data.group.user)
1702
- ) {
1703
- try {
2595
+ try {
2596
+ let group = this.$data.group;
2597
+ if (group != null) {
2598
+ // Make sure to get fresh group data.
2599
+ if (group.$asleep()) {
2600
+ await group.$wake();
2601
+ } else {
2602
+ if (!(await group.$refresh())) {
2603
+ throw new Error("Couldn't retrieve primary group info.");
2604
+ }
2605
+ }
2606
+ }
2607
+ if (group == null && this.guid == null) {
2608
+ if (tilmeld.config.generatePrimary) {
2609
+ // Generate a new primary group for the user.
2610
+ group = await Group.factory();
2611
+ group.user = this;
2612
+ const parent = await tnymph.getEntity(
2613
+ { class: Group },
2614
+ {
2615
+ type: '&',
2616
+ equal: ['defaultPrimary', true],
2617
+ },
2618
+ );
2619
+ if (parent != null) {
2620
+ group.parent = parent;
2621
+ }
2622
+ } else {
2623
+ // Add the default primary.
2624
+ const group = await tnymph.getEntity(
2625
+ { class: Group },
2626
+ {
2627
+ type: '&',
2628
+ equal: ['defaultPrimary', true],
2629
+ },
2630
+ );
2631
+ if (group != null) {
2632
+ this.$data.group = group;
2633
+ }
2634
+ }
2635
+ }
2636
+ if (group != null && group.user != null && this.$is(group.user)) {
1704
2637
  // 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();
2638
+ let changed = false;
2639
+ if (group.groupname !== this.$data.username) {
2640
+ group.groupname = this.$data.username;
2641
+ changed = true;
2642
+ }
2643
+ if (group.avatar !== this.$data.avatar) {
2644
+ group.avatar = this.$data.avatar;
2645
+ changed = true;
2646
+ }
2647
+ if (
2648
+ tilmeld.config.userFields.includes('email') &&
2649
+ group.email !== this.$data.email
2650
+ ) {
2651
+ group.email = this.$data.email;
2652
+ changed = true;
2653
+ }
2654
+ if (
2655
+ tilmeld.config.userFields.includes('name') &&
2656
+ group.name !== this.$data.name
2657
+ ) {
2658
+ group.name = this.$data.name;
2659
+ changed = true;
2660
+ }
2661
+ if (
2662
+ tilmeld.config.userFields.includes('phone') &&
2663
+ group.phone !== this.$data.phone
2664
+ ) {
2665
+ group.phone = this.$data.phone;
2666
+ changed = true;
2667
+ }
2668
+ if (changed || group.guid == null) {
2669
+ if (!(await group.$saveSkipAC())) {
2670
+ throw Error('Error updating primary group for user.');
2671
+ }
2672
+ }
2673
+ this.$data.group = group;
2674
+ }
2675
+
2676
+ if (this.$data.groups == null || this.$data.groups.length === 0) {
2677
+ // Add secondary groups.
2678
+ if (
2679
+ tilmeld.config.userFields.includes('email') &&
2680
+ tilmeld.config.verifyEmail &&
2681
+ tilmeld.config.unverifiedAccess &&
2682
+ this.$data.secret != null
2683
+ ) {
2684
+ // Add the default secondaries for unverified users.
2685
+ this.$data.groups = await tnymph.getEntities(
2686
+ { class: Group },
2687
+ {
2688
+ type: '&',
2689
+ equal: ['unverifiedSecondary', true],
2690
+ },
2691
+ );
2692
+ } else {
2693
+ // Add the default secondaries.
2694
+ this.$data.groups = await tnymph.getEntities(
2695
+ { class: Group },
2696
+ {
2697
+ type: '&',
2698
+ equal: ['defaultSecondary', true],
2699
+ },
2700
+ );
2701
+ }
2702
+ }
2703
+
2704
+ try {
2705
+ tilmeld.config.validatorUser(tilmeld, this);
1711
2706
  } catch (e: any) {
1712
- await tnymph.rollback(transaction);
1713
- this.$nymph = nymph;
1714
- throw e;
2707
+ throw new BadDataError(e?.message);
1715
2708
  }
2709
+ } catch (e: any) {
2710
+ await tnymph.rollback(transaction);
2711
+ this.$setNymph(nymph);
2712
+ throw e;
1716
2713
  }
1717
2714
 
1718
- let ret: boolean;
2715
+ let ret = false;
1719
2716
  let preGuid = this.guid;
1720
2717
  let preCdate = this.cdate;
1721
2718
  let preMdate = this.mdate;
1722
2719
 
1723
2720
  try {
2721
+ for (let callback of (this.constructor as typeof User)
2722
+ .beforeSaveCallbacks) {
2723
+ if (callback) {
2724
+ await callback(this);
2725
+ }
2726
+ }
2727
+
1724
2728
  ret = await super.$save();
1725
2729
  } catch (e: any) {
1726
2730
  await tnymph.rollback(transaction);
1727
- this.$nymph = nymph;
2731
+ this.guid = preGuid;
2732
+ this.cdate = preCdate;
2733
+ this.mdate = preMdate;
2734
+ this.$setNymph(nymph);
1728
2735
  throw e;
1729
2736
  }
2737
+
1730
2738
  if (ret) {
1731
2739
  if (sendVerification) {
1732
2740
  // The email has changed, so send a new verification email.
@@ -1735,23 +2743,48 @@ export default class User extends AbleObject<UserData> {
1735
2743
  this.guid = preGuid;
1736
2744
  this.cdate = preCdate;
1737
2745
  this.mdate = preMdate;
1738
- this.$nymph = nymph;
2746
+ this.$setNymph(nymph);
1739
2747
  throw new Error("Couldn't send verification email.");
1740
2748
  }
1741
2749
  }
1742
2750
 
1743
2751
  this.$descendantGroups = undefined;
1744
2752
  this.$gatekeeperCache = undefined;
1745
- await tnymph.commit(transaction);
1746
2753
 
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);
2754
+ try {
2755
+ for (let callback of (this.constructor as typeof User)
2756
+ .afterSaveCallbacks) {
2757
+ if (callback) {
2758
+ await callback(this);
2759
+ }
2760
+ }
2761
+ } catch (e: any) {
2762
+ await tnymph.rollback(transaction);
2763
+ this.guid = preGuid;
2764
+ this.cdate = preCdate;
2765
+ this.mdate = preMdate;
2766
+ this.$setNymph(nymph);
2767
+ throw e;
1750
2768
  }
2769
+
2770
+ ret = await tnymph.commit(transaction);
1751
2771
  } else {
1752
2772
  await tnymph.rollback(transaction);
1753
2773
  }
1754
- this.$nymph = nymph;
2774
+ this.$setNymph(nymph);
2775
+
2776
+ if (ret) {
2777
+ this.$originalEmail = this.$data.email;
2778
+ this.$originalUsername = this.$data.username;
2779
+
2780
+ const tilmeld = enforceTilmeld(nymph);
2781
+
2782
+ if (tilmeld.User.current(true).$is(this)) {
2783
+ // Update the user in the session cache.
2784
+ await tilmeld.fillSession(this);
2785
+ }
2786
+ }
2787
+
1755
2788
  return ret;
1756
2789
  }
1757
2790
 
@@ -1772,53 +2805,167 @@ export default class User extends AbleObject<UserData> {
1772
2805
  }
1773
2806
 
1774
2807
  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.");
2808
+ const tilmeld = enforceTilmeld(this);
2809
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
2810
+ throw new AccessControlError(
2811
+ "You don't have the authority to delete users.",
2812
+ );
1778
2813
  }
1779
2814
  if (
2815
+ !this.$skipAcWhenDeleting &&
1780
2816
  !tilmeld.gatekeeper('system/admin') &&
1781
- this.$gatekeeper('system/admin')
2817
+ this.$data.abilities?.includes('system/admin')
1782
2818
  ) {
1783
- throw new BadDataError(
1784
- "You don't have the authority to delete system admins."
2819
+ throw new AccessControlError(
2820
+ "You don't have the authority to delete system admins.",
2821
+ );
2822
+ }
2823
+ if (this.$data.username !== this.$originalUsername) {
2824
+ throw new AccessControlError(
2825
+ "You can't delete a user while switching their username.",
1785
2826
  );
1786
2827
  }
1787
- if (tilmeld.User.current(true).$is(this)) {
1788
- await this.$logout();
2828
+
2829
+ const transaction = 'tilmeld-delete-user-' + this.guid;
2830
+ const nymph = this.$nymph;
2831
+ const tnymph = await nymph.startTransaction(transaction);
2832
+ this.$setNymph(tnymph);
2833
+
2834
+ let success = false;
2835
+
2836
+ try {
2837
+ if (!(await this.$refresh())) {
2838
+ throw new BadDataError('User could not be refreshed.');
2839
+ }
2840
+
2841
+ if (this.$data.group != null) {
2842
+ if (this.$data.group.$asleep()) {
2843
+ await this.$data.group.$wake();
2844
+ } else {
2845
+ if (!(await this.$data.group.$refresh())) {
2846
+ throw new BadDataError('Group could not be refreshed.');
2847
+ }
2848
+ }
2849
+ }
2850
+
2851
+ for (let callback of (this.constructor as typeof User)
2852
+ .beforeDeleteCallbacks) {
2853
+ if (callback) {
2854
+ await callback(this);
2855
+ }
2856
+ }
2857
+
2858
+ if (tilmeld.User.current(true).$is(this)) {
2859
+ await this.$logout();
2860
+ }
2861
+
2862
+ if (this.$data.group != null && this.$is(this.$data.group.user)) {
2863
+ // Read the skip ac value here, because super.$delete changes it.
2864
+ const $skipAcWhenDeleting = this.$skipAcWhenDeleting;
2865
+
2866
+ // Delete the user.
2867
+ success = await super.$delete();
2868
+ if (success) {
2869
+ // Delete the generated group.
2870
+ success = $skipAcWhenDeleting
2871
+ ? await this.$data.group.$deleteSkipAC()
2872
+ : await this.$data.group.$delete();
2873
+ }
2874
+ } else {
2875
+ success = await super.$delete();
2876
+ }
2877
+ } catch (e: any) {
2878
+ await tnymph.rollback(transaction);
2879
+ this.$setNymph(nymph);
2880
+ throw e;
2881
+ }
2882
+
2883
+ if (success) {
2884
+ try {
2885
+ for (let callback of (this.constructor as typeof User)
2886
+ .afterDeleteCallbacks) {
2887
+ if (callback) {
2888
+ await callback(this);
2889
+ }
2890
+ }
2891
+ } catch (e: any) {
2892
+ await tnymph.rollback(transaction);
2893
+ this.$setNymph(nymph);
2894
+ throw e;
2895
+ }
2896
+ success = await tnymph.commit(transaction);
2897
+ } else {
2898
+ await tnymph.rollback(transaction);
1789
2899
  }
1790
- return await super.$delete();
2900
+ this.$setNymph(nymph);
2901
+ return success;
2902
+ }
2903
+
2904
+ /*
2905
+ * This should *never* be accessible on the client.
2906
+ */
2907
+ async $deleteSkipAC() {
2908
+ this.$skipAcWhenDeleting = true;
2909
+ return await this.$delete();
2910
+ }
2911
+
2912
+ $tilmeldDeleteSkipAC() {
2913
+ if (this.$skipAcWhenDeleting) {
2914
+ this.$skipAcWhenDeleting = false;
2915
+ return true;
2916
+ }
2917
+ return false;
1791
2918
  }
1792
2919
 
1793
2920
  public static on<T extends EventType>(
1794
2921
  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
2922
+ callback: T extends 'checkUsername'
2923
+ ? TilmeldCheckUsernameCallback
2924
+ : T extends 'beforeRegister'
2925
+ ? TilmeldBeforeRegisterCallback
2926
+ : T extends 'afterRegister'
2927
+ ? TilmeldAfterRegisterCallback
2928
+ : T extends 'beforeSave'
2929
+ ? TilmeldBeforeSaveCallback
2930
+ : T extends 'afterSave'
2931
+ ? TilmeldAfterSaveCallback
2932
+ : T extends 'beforeLogin'
2933
+ ? TilmeldBeforeLoginCallback
2934
+ : T extends 'afterLogin'
2935
+ ? TilmeldAfterLoginCallback
2936
+ : T extends 'beforeLogout'
2937
+ ? TilmeldBeforeLogoutCallback
2938
+ : T extends 'afterLogout'
2939
+ ? TilmeldAfterLogoutCallback
2940
+ : T extends 'beforeDelete'
2941
+ ? TilmeldBeforeDeleteCallback
2942
+ : T extends 'afterDelete'
2943
+ ? TilmeldAfterDeleteCallback
2944
+ : never,
1808
2945
  ) {
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;
2946
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2947
+ ? 'checkUsernameCallbacks'
2948
+ : T extends 'beforeRegister'
2949
+ ? 'beforeRegisterCallbacks'
2950
+ : T extends 'afterRegister'
2951
+ ? 'afterRegisterCallbacks'
2952
+ : T extends 'beforeSave'
2953
+ ? 'beforeSaveCallbacks'
2954
+ : T extends 'afterSave'
2955
+ ? 'afterSaveCallbacks'
2956
+ : T extends 'beforeLogin'
2957
+ ? 'beforeLoginCallbacks'
2958
+ : T extends 'afterLogin'
2959
+ ? 'afterLoginCallbacks'
2960
+ : T extends 'beforeLogout'
2961
+ ? 'beforeLogoutCallbacks'
2962
+ : T extends 'afterLogout'
2963
+ ? 'afterLogoutCallbacks'
2964
+ : T extends 'beforeDelete'
2965
+ ? 'beforeDeleteCallbacks'
2966
+ : T extends 'afterDelete'
2967
+ ? 'afterDeleteCallbacks'
2968
+ : never;
1822
2969
  if (!(prop in this)) {
1823
2970
  throw new Error('Invalid event type.');
1824
2971
  }
@@ -1829,33 +2976,53 @@ export default class User extends AbleObject<UserData> {
1829
2976
 
1830
2977
  public static off<T extends EventType>(
1831
2978
  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
2979
+ callback: T extends 'checkUsername'
2980
+ ? TilmeldCheckUsernameCallback
2981
+ : T extends 'beforeRegister'
2982
+ ? TilmeldBeforeRegisterCallback
2983
+ : T extends 'afterRegister'
2984
+ ? TilmeldAfterRegisterCallback
2985
+ : T extends 'beforeSave'
2986
+ ? TilmeldBeforeSaveCallback
2987
+ : T extends 'afterSave'
2988
+ ? TilmeldAfterSaveCallback
2989
+ : T extends 'beforeLogin'
2990
+ ? TilmeldBeforeLoginCallback
2991
+ : T extends 'afterLogin'
2992
+ ? TilmeldAfterLoginCallback
2993
+ : T extends 'beforeLogout'
2994
+ ? TilmeldBeforeLogoutCallback
2995
+ : T extends 'afterLogout'
2996
+ ? TilmeldAfterLogoutCallback
2997
+ : T extends 'beforeDelete'
2998
+ ? TilmeldBeforeDeleteCallback
2999
+ : T extends 'afterDelete'
3000
+ ? TilmeldAfterDeleteCallback
3001
+ : never,
1845
3002
  ) {
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;
3003
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
3004
+ ? 'checkUsernameCallbacks'
3005
+ : T extends 'beforeRegister'
3006
+ ? 'beforeRegisterCallbacks'
3007
+ : T extends 'afterRegister'
3008
+ ? 'afterRegisterCallbacks'
3009
+ : T extends 'beforeSave'
3010
+ ? 'beforeSaveCallbacks'
3011
+ : T extends 'afterSave'
3012
+ ? 'afterSaveCallbacks'
3013
+ : T extends 'beforeLogin'
3014
+ ? 'beforeLoginCallbacks'
3015
+ : T extends 'afterLogin'
3016
+ ? 'afterLoginCallbacks'
3017
+ : T extends 'beforeLogout'
3018
+ ? 'beforeLogoutCallbacks'
3019
+ : T extends 'afterLogout'
3020
+ ? 'afterLogoutCallbacks'
3021
+ : T extends 'beforeDelete'
3022
+ ? 'beforeDeleteCallbacks'
3023
+ : T extends 'afterDelete'
3024
+ ? 'afterDeleteCallbacks'
3025
+ : never;
1859
3026
  if (!(prop in this)) {
1860
3027
  return false;
1861
3028
  }