@nymphjs/tilmeld 1.0.0-beta.10 → 1.0.0-beta.100

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