@nymphjs/tilmeld 1.0.0-beta.8 → 1.0.0-beta.81

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 (57) hide show
  1. package/CHANGELOG.md +368 -0
  2. package/README.md +14 -1
  3. package/dist/AbleObject.d.ts +15 -0
  4. package/dist/AbleObject.js +16 -1
  5. package/dist/AbleObject.js.map +1 -1
  6. package/dist/Group.d.ts +130 -10
  7. package/dist/Group.js +311 -129
  8. package/dist/Group.js.map +1 -1
  9. package/dist/Group.test.js +77 -2
  10. package/dist/Group.test.js.map +1 -1
  11. package/dist/Tilmeld.d.ts +237 -9
  12. package/dist/Tilmeld.js +569 -201
  13. package/dist/Tilmeld.js.map +1 -1
  14. package/dist/User.d.ts +344 -16
  15. package/dist/User.js +979 -313
  16. package/dist/User.js.map +1 -1
  17. package/dist/User.test.js +63 -2
  18. package/dist/User.test.js.map +1 -1
  19. package/dist/conf/d.d.ts +250 -3
  20. package/dist/conf/defaults.js +146 -98
  21. package/dist/conf/defaults.js.map +1 -1
  22. package/dist/enforceTilmeld.d.ts +3 -0
  23. package/dist/enforceTilmeld.js +22 -0
  24. package/dist/enforceTilmeld.js.map +1 -0
  25. package/dist/errors/AccessControlError.d.ts +2 -1
  26. package/dist/errors/AccessControlError.js +3 -2
  27. package/dist/errors/AccessControlError.js.map +1 -1
  28. package/dist/errors/BadDataError.d.ts +2 -1
  29. package/dist/errors/BadDataError.js +3 -2
  30. package/dist/errors/BadDataError.js.map +1 -1
  31. package/dist/errors/BadEmailError.d.ts +2 -1
  32. package/dist/errors/BadEmailError.js +3 -2
  33. package/dist/errors/BadEmailError.js.map +1 -1
  34. package/dist/errors/BadUsernameError.d.ts +2 -1
  35. package/dist/errors/BadUsernameError.js +3 -2
  36. package/dist/errors/BadUsernameError.js.map +1 -1
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +30 -26
  41. package/src/AbleObject.ts +2 -2
  42. package/src/Group.test.ts +102 -4
  43. package/src/Group.ts +274 -158
  44. package/src/Tilmeld.ts +383 -249
  45. package/src/User.test.ts +80 -4
  46. package/src/User.ts +1061 -382
  47. package/src/conf/d.ts +26 -13
  48. package/src/conf/defaults.ts +151 -104
  49. package/src/enforceTilmeld.ts +20 -0
  50. package/src/errors/AccessControlError.ts +4 -2
  51. package/src/errors/BadDataError.ts +4 -2
  52. package/src/errors/BadEmailError.ts +4 -2
  53. package/src/errors/BadUsernameError.ts +4 -2
  54. package/src/index.ts +2 -0
  55. package/src/tilmeldJoiProps.ts +9 -9
  56. package/tsconfig.json +3 -3
  57. package/typedoc.json +4 -0
package/src/User.ts CHANGED
@@ -1,21 +1,25 @@
1
- import {
1
+ import type {
2
+ Nymph,
2
3
  EntityData,
3
4
  EntityJson,
4
5
  EntityPatch,
5
6
  Selector,
6
7
  SerializedEntityData,
7
8
  } from '@nymphjs/nymph';
8
- import { EmailOptions } from 'email-templates';
9
+ import { humanSecret, nanoid } from '@nymphjs/guid';
10
+ import type { EmailOptions } from 'email-templates';
9
11
  import strtotime from 'locutus/php/datetime/strtotime';
10
12
  import Base64 from 'crypto-js/enc-base64';
11
13
  import sha256 from 'crypto-js/sha256';
12
14
  import md5 from 'crypto-js/md5';
13
15
  import { difference } from 'lodash';
14
- import { humanSecret, nanoid } from '@nymphjs/guid';
16
+ import { TOTP, Secret } from 'otpauth';
17
+ import { toDataURL } from 'qrcode';
15
18
 
16
- import type Tilmeld from './Tilmeld';
19
+ import { enforceTilmeld } from './enforceTilmeld';
17
20
  import AbleObject from './AbleObject';
18
- import Group, { GroupData } from './Group';
21
+ import type Group from './Group';
22
+ import type { GroupData } from './Group';
19
23
  import {
20
24
  BadDataError,
21
25
  BadEmailError,
@@ -24,23 +28,33 @@ import {
24
28
  } from './errors';
25
29
 
26
30
  export type EventType =
31
+ | 'checkUsername'
27
32
  | 'beforeRegister'
28
33
  | 'afterRegister'
29
34
  | 'beforeLogin'
30
35
  | 'afterLogin'
31
36
  | 'beforeLogout'
32
37
  | 'afterLogout';
38
+ /**
39
+ * This is run when the user has entered an otherwise valid username into the
40
+ * signup form. It should return a result, which when false will stop the
41
+ * process and return the included message, disallowing the username.
42
+ */
43
+ export type TilmeldCheckUsernameCallback = (
44
+ user: User & UserData,
45
+ data: { username: string },
46
+ ) => Promise<{ result: boolean; message?: string }>;
33
47
  /**
34
48
  * Theses are run before the user data checks, so the only checks before are
35
49
  * whether registration is allowed and whether the user is already registered.
36
50
  */
37
51
  export type TilmeldBeforeRegisterCallback = (
38
52
  user: User & UserData,
39
- data: { password: string; additionalData?: { [k: string]: any } }
53
+ data: { password: string; additionalData?: { [k: string]: any } },
40
54
  ) => Promise<void>;
41
55
  export type TilmeldAfterRegisterCallback = (
42
56
  user: User & UserData,
43
- result: { loggedin: boolean; message: string }
57
+ result: { loggedin: boolean; message: string },
44
58
  ) => Promise<void>;
45
59
  /**
46
60
  * These are run after the authentication checks, but before the login action.
@@ -51,20 +65,20 @@ export type TilmeldBeforeLoginCallback = (
51
65
  username: string;
52
66
  password: string;
53
67
  additionalData?: { [k: string]: any };
54
- }
68
+ },
55
69
  ) => Promise<void>;
56
70
  /**
57
71
  * This is run before the transaction is committed, and you can perform
58
72
  * additional functions on the transaction, which is available in `user.$nymph`.
59
73
  */
60
74
  export type TilmeldAfterLoginCallback = (
61
- user: User & UserData
75
+ user: User & UserData,
62
76
  ) => Promise<void>;
63
77
  export type TilmeldBeforeLogoutCallback = (
64
- user: User & UserData
78
+ user: User & UserData,
65
79
  ) => Promise<void>;
66
80
  export type TilmeldAfterLogoutCallback = (
67
- user: User & UserData
81
+ user: User & UserData,
68
82
  ) => Promise<void>;
69
83
 
70
84
  export type UserData = {
@@ -135,7 +149,8 @@ export type UserData = {
135
149
  */
136
150
  newEmailSecret?: string;
137
151
  /**
138
- * The new email address.
152
+ * If the user has changed their email address, this is the new one, awaiting
153
+ * verification.
139
154
  */
140
155
  newEmailAddress?: string;
141
156
  /**
@@ -167,6 +182,15 @@ export type UserData = {
167
182
  * database.
168
183
  */
169
184
  passwordTemp?: string;
185
+ /**
186
+ * If set, this timestamp is the cutoff point for JWT issue dates. Any token
187
+ * issued before this date will not authenticate the user.
188
+ */
189
+ revokeTokenDate?: number;
190
+ /**
191
+ * Two factor auth secret.
192
+ */
193
+ totpSecret?: string;
170
194
  };
171
195
 
172
196
  /**
@@ -182,9 +206,9 @@ export default class User extends AbleObject<UserData> {
182
206
  /**
183
207
  * The instance of Tilmeld to use for queries.
184
208
  */
185
- static tilmeld: Tilmeld;
186
209
  static ETYPE = 'tilmeld_user';
187
210
  static class = 'User';
211
+ private static checkUsernameCallbacks: TilmeldCheckUsernameCallback[] = [];
188
212
  private static beforeRegisterCallbacks: TilmeldBeforeRegisterCallback[] = [];
189
213
  private static afterRegisterCallbacks: TilmeldAfterRegisterCallback[] = [];
190
214
  private static beforeLoginCallbacks: TilmeldBeforeLoginCallback[] = [];
@@ -200,23 +224,33 @@ export default class User extends AbleObject<UserData> {
200
224
  '$register',
201
225
  ];
202
226
  private static DEFAULT_PRIVATE_DATA = [
227
+ // The things that are commented are there to show what is _not_ private.
228
+ 'abilities',
229
+ // 'username',
230
+ // 'nameFirst',
231
+ // 'nameMiddle',
232
+ // 'nameLast',
233
+ // 'name',
203
234
  'email',
204
- 'originalEmail',
235
+ // 'avatar',
205
236
  'phone',
206
237
  'group',
207
238
  'groups',
208
- 'abilities',
209
239
  'inheritAbilities',
210
- 'recoverSecret',
211
- 'recoverSecretDate',
212
- 'password',
213
- 'salt',
240
+ 'enabled',
214
241
  'secret',
215
- 'newEmailAddress',
242
+ 'emailChangeDate',
216
243
  'newEmailSecret',
217
- 'cancelEmailAddress',
244
+ 'newEmailAddress',
218
245
  'cancelEmailSecret',
219
- 'emailChangeDate',
246
+ 'cancelEmailAddress',
247
+ 'recoverSecret',
248
+ 'recoverSecretDate',
249
+ 'salt',
250
+ 'password',
251
+ 'passwordTemp',
252
+ 'revokeTokenDate',
253
+ 'totpSecret',
220
254
  ];
221
255
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
222
256
 
@@ -241,9 +275,13 @@ export default class User extends AbleObject<UserData> {
241
275
  */
242
276
  private $gatekeeperCache?: { [k: string]: true };
243
277
  /**
244
- * This is explicitly used only during the registration proccess.
278
+ * This should only be used by the backend.
245
279
  */
246
280
  private $skipAcWhenSaving = false;
281
+ /**
282
+ * This should only be used by the backend.
283
+ */
284
+ private $skipAcWhenDeleting = false;
247
285
  /**
248
286
  * The user's group descendants.
249
287
  */
@@ -257,10 +295,6 @@ export default class User extends AbleObject<UserData> {
257
295
  */
258
296
  public $originalEmail?: string;
259
297
 
260
- static async factory(guid?: string): Promise<User & UserData> {
261
- return (await super.factory(guid)) as User & UserData;
262
- }
263
-
264
298
  static async factoryUsername(username?: string): Promise<User & UserData> {
265
299
  const entity = new this();
266
300
  if (username != null) {
@@ -271,7 +305,7 @@ export default class User extends AbleObject<UserData> {
271
305
  {
272
306
  type: '&',
273
307
  ilike: ['username', username.replace(/([\\%_])/g, (s) => `\\${s}`)],
274
- }
308
+ },
275
309
  );
276
310
  if (entity != null) {
277
311
  return entity;
@@ -280,34 +314,18 @@ export default class User extends AbleObject<UserData> {
280
314
  return entity;
281
315
  }
282
316
 
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
317
  public static current(returnObjectIfNotExist: true): User & UserData;
301
318
  public static current(
302
- returnObjectIfNotExist?: false
319
+ returnObjectIfNotExist?: false,
303
320
  ): (User & UserData) | null;
304
321
  public static current(
305
- returnObjectIfNotExist?: boolean
322
+ returnObjectIfNotExist?: boolean,
306
323
  ): (User & UserData) | null {
307
- if (this.tilmeld.currentUser == null) {
324
+ const tilmeld = enforceTilmeld(this);
325
+ if (tilmeld.currentUser == null) {
308
326
  return returnObjectIfNotExist ? this.factorySync() : null;
309
327
  }
310
- return this.tilmeld.currentUser;
328
+ return tilmeld.currentUser;
311
329
  }
312
330
 
313
331
  /**
@@ -320,7 +338,8 @@ export default class User extends AbleObject<UserData> {
320
338
  recoveryType: 'username' | 'password';
321
339
  account: string;
322
340
  }): Promise<{ result: boolean; message: string }> {
323
- if (!this.tilmeld.config.pwRecovery) {
341
+ const tilmeld = enforceTilmeld(this);
342
+ if (!tilmeld.config.pwRecovery) {
324
343
  return {
325
344
  result: false,
326
345
  message: 'Account recovery is not allowed.',
@@ -330,18 +349,15 @@ export default class User extends AbleObject<UserData> {
330
349
  let user: User & UserData;
331
350
  const options: EmailOptions = {};
332
351
 
333
- if (
334
- !this.tilmeld.config.emailUsernames &&
335
- data.recoveryType === 'username'
336
- ) {
352
+ if (!tilmeld.config.emailUsernames && data.recoveryType === 'username') {
337
353
  // Create a username recovery email.
338
354
 
339
355
  const getUser = await this.nymph.getEntity(
340
- { class: this.tilmeld.User, skipAc: true },
356
+ { class: tilmeld.User, skipAc: true },
341
357
  {
342
358
  type: '&',
343
359
  ilike: ['email', data.account.replace(/([\\%_])/g, (s) => `\\${s}`)],
344
- }
360
+ },
345
361
  );
346
362
 
347
363
  if (getUser == null) {
@@ -364,7 +380,7 @@ export default class User extends AbleObject<UserData> {
364
380
  } else if (data.recoveryType === 'password') {
365
381
  // Create a password recovery email.
366
382
 
367
- const getUser = await this.tilmeld.User.factoryUsername(data.account);
383
+ const getUser = await tilmeld.User.factoryUsername(data.account);
368
384
 
369
385
  if (getUser.guid == null) {
370
386
  return {
@@ -391,14 +407,19 @@ export default class User extends AbleObject<UserData> {
391
407
  };
392
408
  options.locals = {
393
409
  recoverCode: getUser.recoverSecret,
394
- timeLimit: this.tilmeld.config.pwRecoveryTimeLimit,
410
+ timeLimit: tilmeld.config.pwRecoveryTimeLimit,
395
411
  };
396
412
  } else {
397
413
  return { result: false, message: 'Invalid recovery type.' };
398
414
  }
399
415
 
416
+ this.nymph.config.debugLog(
417
+ 'tilmeld',
418
+ `User account recovery (${options.template}) request for "${user.username}".`,
419
+ );
420
+
400
421
  // Send the email.
401
- if (await this.tilmeld.config.sendEmail(this.tilmeld, options, user)) {
422
+ if (await tilmeld.config.sendEmail(tilmeld, options, user)) {
402
423
  return {
403
424
  result: true,
404
425
  message:
@@ -421,22 +442,23 @@ export default class User extends AbleObject<UserData> {
421
442
  secret: string;
422
443
  password: string;
423
444
  }): Promise<{ result: boolean; message: string }> {
424
- if (!this.tilmeld.config.pwRecovery) {
445
+ const tilmeld = enforceTilmeld(this);
446
+ if (!tilmeld.config.pwRecovery) {
425
447
  return {
426
448
  result: false,
427
449
  message: 'Account recovery is not allowed.',
428
450
  };
429
451
  }
430
452
 
431
- const user = await this.tilmeld.User.factoryUsername(data.username);
453
+ const user = await tilmeld.User.factoryUsername(data.username);
432
454
 
433
455
  if (
434
456
  user.guid == null ||
435
457
  user.recoverSecret == null ||
436
458
  data.secret !== user.recoverSecret ||
437
459
  strtotime(
438
- '+' + this.tilmeld.config.pwRecoveryTimeLimit,
439
- Math.floor((user.recoverSecretDate ?? 0) / 1000)
460
+ '+' + tilmeld.config.pwRecoveryTimeLimit,
461
+ Math.floor((user.recoverSecretDate ?? 0) / 1000),
440
462
  ) *
441
463
  1000 <
442
464
  Date.now()
@@ -451,6 +473,11 @@ export default class User extends AbleObject<UserData> {
451
473
  return { result: false, message: 'Password cannot be empty.' };
452
474
  }
453
475
 
476
+ this.nymph.config.debugLog(
477
+ 'tilmeld',
478
+ `User account recovery for "${user.username}".`,
479
+ );
480
+
454
481
  user.$password(data.password);
455
482
  delete user.recoverSecret;
456
483
  delete user.recoverSecretDate;
@@ -466,27 +493,30 @@ export default class User extends AbleObject<UserData> {
466
493
  }
467
494
 
468
495
  public static getClientConfig() {
496
+ const tilmeld = enforceTilmeld(this);
469
497
  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,
498
+ regFields: tilmeld.config.regFields,
499
+ userFields: tilmeld.config.userFields,
500
+ emailUsernames: tilmeld.config.emailUsernames,
501
+ allowRegistration: tilmeld.config.allowRegistration,
502
+ allowUsernameChange: tilmeld.config.allowUsernameChange,
503
+ pwRecovery: tilmeld.config.pwRecovery,
504
+ verifyEmail: tilmeld.config.verifyEmail,
505
+ unverifiedAccess: tilmeld.config.unverifiedAccess,
478
506
  };
479
507
  }
480
508
 
481
509
  public static async loginUser(data: {
482
510
  username: string;
483
511
  password: string;
512
+ code?: string;
484
513
  additionalData?: { [k: string]: any };
485
514
  }) {
515
+ const tilmeld = enforceTilmeld(this);
486
516
  if (!('username' in data) || !data.username.length) {
487
517
  return { result: false, message: 'Incorrect login/password.' };
488
518
  }
489
- const user = await this.tilmeld.User.factoryUsername(data.username);
519
+ const user = await tilmeld.User.factoryUsername(data.username);
490
520
  const result: { result: boolean; message: string; user?: User & UserData } =
491
521
  await user.$login(data);
492
522
  if (result.result) {
@@ -496,25 +526,85 @@ export default class User extends AbleObject<UserData> {
496
526
  return result;
497
527
  }
498
528
 
529
+ constructor() {
530
+ super();
531
+
532
+ // Defaults.
533
+ this.$data.enabled = true;
534
+ this.$data.abilities = [];
535
+ this.$data.groups = [];
536
+ this.$data.inheritAbilities = true;
537
+ this.$updateDataProtection();
538
+ }
539
+
540
+ $setNymph(nymph: Nymph) {
541
+ this.$nymph = nymph;
542
+ if (!this.$asleep()) {
543
+ if (this.$data.group && this.$data.group.$nymph !== nymph) {
544
+ this.$data.group.$setNymph(nymph);
545
+ }
546
+ for (const group of this.$data.groups ?? []) {
547
+ if (group && group.$nymph !== nymph) {
548
+ group.$setNymph(nymph);
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ async $getUniques(): Promise<string[]> {
555
+ const tilmeld = enforceTilmeld(this);
556
+ const uniques = [`u:${this.$data.username}`];
557
+ if (
558
+ !tilmeld.config.emailUsernames &&
559
+ tilmeld.config.userFields.includes('email')
560
+ ) {
561
+ uniques.push(`e:${this.$data.email}`);
562
+
563
+ if (this.$data.newEmailAddress != null) {
564
+ uniques.push(`e:${this.$data.newEmailAddress}`);
565
+ }
566
+ }
567
+ return uniques;
568
+ }
569
+
499
570
  public async $login(data: {
500
571
  username: string;
501
572
  password: string;
573
+ code?: string;
502
574
  additionalData?: { [k: string]: any };
503
575
  }) {
504
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
576
+ const tilmeld = enforceTilmeld(this);
505
577
  if (this.guid == null) {
506
578
  return { result: false, message: 'Incorrect login/password.' };
507
579
  }
508
580
  if (!this.$data.enabled) {
509
581
  return { result: false, message: 'This user is disabled.' };
510
582
  }
511
- if (this.$gatekeeper()) {
583
+ if (await this.$gatekeeper()) {
512
584
  return { result: true, message: 'You are already logged in.' };
513
585
  }
514
586
  if (!this.$checkPassword(data.password)) {
515
587
  return { result: false, message: 'Incorrect login/password.' };
516
588
  }
517
589
 
590
+ if (this.$data.totpSecret != null) {
591
+ if (
592
+ data.code == null ||
593
+ typeof data.code !== 'string' ||
594
+ data.code.length !== 6
595
+ ) {
596
+ return {
597
+ result: false,
598
+ needTOTP: true,
599
+ message: 'You need to provide a 2FA code.',
600
+ };
601
+ }
602
+
603
+ if (!this.$checkTOTPCode(data.code)) {
604
+ return { result: false, message: 'Incorrect 2FA code.' };
605
+ }
606
+ }
607
+
518
608
  try {
519
609
  for (let callback of (this.constructor as typeof User)
520
610
  .beforeLoginCallbacks) {
@@ -529,8 +619,90 @@ export default class User extends AbleObject<UserData> {
529
619
  };
530
620
  }
531
621
 
622
+ this.$nymph.config.debugLog(
623
+ 'tilmeld',
624
+ `User login for "${this.$data.username}".`,
625
+ );
626
+
627
+ // Authentication was successful, attempt to login.
628
+ if (!(await tilmeld.login(this, true))) {
629
+ return { result: false, message: 'Incorrect login/password.' };
630
+ }
631
+
632
+ for (let callback of (this.constructor as typeof User)
633
+ .afterLoginCallbacks) {
634
+ if (callback) {
635
+ await callback(this);
636
+ }
637
+ }
638
+
639
+ // Login was successful.
640
+ return { result: true, message: 'You are logged in.' };
641
+ }
642
+
643
+ public async $switchUser(data?: { additionalData?: { [k: string]: any } }) {
644
+ const tilmeld = enforceTilmeld(this);
645
+ if (this.guid == null) {
646
+ return { result: false, message: 'Incorrect login/password.' };
647
+ }
648
+
649
+ let user = tilmeld.User.current();
650
+
651
+ if (!user || user.guid == null) {
652
+ return { result: false, message: "You're not logged in." };
653
+ }
654
+
655
+ const isCurrentUser = user != null && user.$is(this);
656
+
657
+ if (isCurrentUser) {
658
+ return { result: false, message: "You can't switch to yourself." };
659
+ }
660
+
661
+ const toAbilities = this.$data.abilities || [];
662
+ const fromAbilities = user.abilities || [];
663
+
664
+ if (
665
+ !fromAbilities.includes('tilmeld/switch') &&
666
+ !fromAbilities.includes('system/admin')
667
+ ) {
668
+ return {
669
+ result: false,
670
+ message: "You don't have permission to switch users.",
671
+ };
672
+ }
673
+
674
+ if (
675
+ toAbilities.includes('system/admin') ||
676
+ toAbilities.includes('tilmeld/admin')
677
+ ) {
678
+ return { result: false, message: "You can't switch to an admin." };
679
+ }
680
+
681
+ try {
682
+ for (let callback of (this.constructor as typeof User)
683
+ .beforeLoginCallbacks) {
684
+ if (callback) {
685
+ await callback(this, {
686
+ username: this.$data.username || '',
687
+ password: '',
688
+ ...(data || {}),
689
+ });
690
+ }
691
+ }
692
+ } catch (e: any) {
693
+ return {
694
+ result: false,
695
+ message: e.message,
696
+ };
697
+ }
698
+
699
+ this.$nymph.config.debugLog(
700
+ 'tilmeld',
701
+ `User login switch for "${user.username}" to "${this.$data.username}".`,
702
+ );
703
+
532
704
  // Authentication was successful, attempt to login.
533
- if (!tilmeld.login(this, true)) {
705
+ if (!(await tilmeld.loginSwitch(this, true))) {
534
706
  return { result: false, message: 'Incorrect login/password.' };
535
707
  }
536
708
 
@@ -550,7 +722,7 @@ export default class User extends AbleObject<UserData> {
550
722
  * @returns An object with a boolean 'result' entry and a 'message' entry.
551
723
  */
552
724
  public async $logout() {
553
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
725
+ const tilmeld = enforceTilmeld(this);
554
726
 
555
727
  try {
556
728
  for (let callback of (this.constructor as typeof User)
@@ -566,7 +738,12 @@ export default class User extends AbleObject<UserData> {
566
738
  };
567
739
  }
568
740
 
569
- tilmeld.logout();
741
+ this.$nymph.config.debugLog(
742
+ 'tilmeld',
743
+ `User logout for "${this.$data.username}".`,
744
+ );
745
+
746
+ await tilmeld.logout();
570
747
 
571
748
  for (let callback of (this.constructor as typeof User)
572
749
  .afterLogoutCallbacks) {
@@ -592,36 +769,36 @@ export default class User extends AbleObject<UserData> {
592
769
  );
593
770
  }
594
771
 
595
- /**
596
- * Get the user's group descendants.
597
- */
598
- public async $getDescendantGroups(): Promise<(Group & GroupData)[]> {
599
- if (this.$descendantGroups == null) {
600
- this.$descendantGroups = [];
601
- if (this.$data.group != null) {
602
- this.$descendantGroups = await this.$data.group.$getDescendants();
603
- }
604
- for (let curGroup of this.$data.groups ?? []) {
605
- this.$descendantGroups = this.$descendantGroups?.concat(
606
- await curGroup.$getDescendants()
607
- );
608
- }
772
+ public $getGid() {
773
+ if (this.$data.group) {
774
+ return this.$data.group.guid;
609
775
  }
610
- return this.$descendantGroups;
776
+ return null;
777
+ }
778
+
779
+ public $getGids() {
780
+ if (this.$data.groups) {
781
+ return this.$data.groups
782
+ .map((group) => group.guid)
783
+ .filter((guid) => guid != null) as string[];
784
+ }
785
+ return null;
611
786
  }
612
787
 
613
788
  /**
614
789
  * Get the user's group descendants.
615
790
  */
616
- public $getDescendantGroupsSync(): (Group & GroupData)[] {
791
+ public async $getDescendantGroups(): Promise<(Group & GroupData)[]> {
617
792
  if (this.$descendantGroups == null) {
618
793
  this.$descendantGroups = [];
619
794
  if (this.$data.group != null) {
620
- this.$descendantGroups = this.$data.group.$getDescendantsSync();
795
+ await this.$data.group.$wake();
796
+ this.$descendantGroups = await this.$data.group.$getDescendants();
621
797
  }
798
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
622
799
  for (let curGroup of this.$data.groups ?? []) {
623
800
  this.$descendantGroups = this.$descendantGroups?.concat(
624
- curGroup.$getDescendantsSync()
801
+ await curGroup.$getDescendants(),
625
802
  );
626
803
  }
627
804
  }
@@ -629,18 +806,42 @@ export default class User extends AbleObject<UserData> {
629
806
  }
630
807
 
631
808
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
632
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
633
- this.$referenceWake();
809
+ const tilmeld = enforceTilmeld(this);
810
+ this.$check();
634
811
 
635
812
  if (
636
813
  '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')
814
+ input.data.abilities?.includes('system/admin') !==
815
+ this.$data.abilities?.includes('system/admin') &&
816
+ (!tilmeld.currentUser ||
817
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
818
+ ) {
819
+ throw new BadDataError(
820
+ "You don't have the authority to grant or revoke system/admin.",
821
+ );
822
+ }
823
+
824
+ if (
825
+ 'abilities' in input.data &&
826
+ input.data.abilities?.includes('tilmeld/admin') !==
827
+ this.$data.abilities?.includes('tilmeld/admin') &&
828
+ (!tilmeld.currentUser ||
829
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
830
+ ) {
831
+ throw new BadDataError(
832
+ "You don't have the authority to grant or revoke tilmeld/admin.",
833
+ );
834
+ }
835
+
836
+ if (
837
+ 'abilities' in input.data &&
838
+ input.data.abilities?.includes('tilmeld/switch') !==
839
+ this.$data.abilities?.includes('tilmeld/switch') &&
840
+ (!tilmeld.currentUser ||
841
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
641
842
  ) {
642
843
  throw new BadDataError(
643
- "You don't have the authority to make this user a system admin."
844
+ "You don't have the authority to grant or revoke tilmeld/switch.",
644
845
  );
645
846
  }
646
847
 
@@ -649,18 +850,42 @@ export default class User extends AbleObject<UserData> {
649
850
  }
650
851
 
651
852
  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
652
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
653
- this.$referenceWake();
853
+ const tilmeld = enforceTilmeld(this);
854
+ this.$check();
654
855
 
655
856
  if (
656
857
  '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')
858
+ patch.set.abilities?.includes('system/admin') !==
859
+ this.$data.abilities?.includes('system/admin') &&
860
+ (!tilmeld.currentUser ||
861
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
862
+ ) {
863
+ throw new BadDataError(
864
+ "You don't have the authority to grant or revoke system/admin.",
865
+ );
866
+ }
867
+
868
+ if (
869
+ 'abilities' in patch.set &&
870
+ patch.set.abilities?.includes('tilmeld/admin') !==
871
+ this.$data.abilities?.includes('tilmeld/admin') &&
872
+ (!tilmeld.currentUser ||
873
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
874
+ ) {
875
+ throw new BadDataError(
876
+ "You don't have the authority to grant or revoke tilmeld/admin.",
877
+ );
878
+ }
879
+
880
+ if (
881
+ 'abilities' in patch.set &&
882
+ patch.set.abilities?.includes('tilmeld/switch') !==
883
+ this.$data.abilities?.includes('tilmeld/switch') &&
884
+ (!tilmeld.currentUser ||
885
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
661
886
  ) {
662
887
  throw new BadDataError(
663
- "You don't have the authority to make this user a system admin."
888
+ "You don't have the authority to grant or revoke tilmeld/switch.",
664
889
  );
665
890
  }
666
891
 
@@ -679,7 +904,7 @@ export default class User extends AbleObject<UserData> {
679
904
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
680
905
  */
681
906
  public $updateDataProtection(givenUser?: User & UserData) {
682
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
907
+ const tilmeld = enforceTilmeld(this);
683
908
  let user = givenUser ?? tilmeld.User.current();
684
909
 
685
910
  this.$clientEnabledMethods = [...User.DEFAULT_CLIENT_ENABLED_METHODS];
@@ -703,17 +928,24 @@ export default class User extends AbleObject<UserData> {
703
928
  // Users can check to see what abilities they have.
704
929
  this.$clientEnabledMethods.push('$gatekeeper');
705
930
  this.$clientEnabledMethods.push('$changePassword');
931
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
706
932
  this.$clientEnabledMethods.push('$logout');
707
933
  this.$clientEnabledMethods.push('$sendEmailVerification');
934
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
935
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
936
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
937
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
708
938
  }
709
939
 
710
940
  if (
711
941
  user != null &&
712
- (abilities.indexOf('tilmeld/admin') !== -1 ||
713
- abilities.indexOf('system/admin') !== -1)
942
+ (abilities.includes('tilmeld/admin') ||
943
+ abilities.includes('system/admin'))
714
944
  ) {
715
945
  // Users who can edit other users can see most of their data.
716
- this.$privateData = ['password', 'salt'];
946
+ this.$privateData = ['password', 'salt', 'totpSecret'];
947
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
948
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
717
949
  this.$allowlistData = undefined;
718
950
  } else if (isCurrentUser || isNewUser) {
719
951
  // Users can see their own data, and edit some of it.
@@ -721,30 +953,69 @@ export default class User extends AbleObject<UserData> {
721
953
  this.$allowlistData.push('username');
722
954
  }
723
955
  this.$allowlistData.push('avatar');
724
- if (tilmeld.config.userFields.indexOf('name') !== -1) {
956
+ if (tilmeld.config.userFields.includes('name')) {
725
957
  this.$allowlistData.push('nameFirst');
726
958
  this.$allowlistData.push('nameMiddle');
727
959
  this.$allowlistData.push('nameLast');
728
960
  this.$allowlistData.push('name');
729
961
  }
730
- if (tilmeld.config.userFields.indexOf('email') !== -1) {
962
+ if (tilmeld.config.userFields.includes('email')) {
731
963
  this.$allowlistData.push('email');
732
964
  }
733
- if (tilmeld.config.userFields.indexOf('phone') !== -1) {
965
+ if (tilmeld.config.userFields.includes('phone')) {
734
966
  this.$allowlistData.push('phone');
735
967
  }
736
968
  this.$privateData = [
969
+ // The things that are commented are there to show what is _not_ private.
970
+ // 'abilities',
971
+ // 'username',
972
+ // 'nameFirst',
973
+ // 'nameMiddle',
974
+ // 'nameLast',
975
+ // 'name',
976
+ // 'email',
977
+ // 'avatar',
978
+ // 'phone',
979
+ // 'group',
980
+ // 'groups',
981
+ // 'inheritAbilities',
982
+ 'enabled',
737
983
  'secret',
984
+ 'emailChangeDate',
738
985
  'newEmailSecret',
739
- 'cancelEmailAddress',
986
+ // 'newEmailAddress',
740
987
  'cancelEmailSecret',
741
- 'emailChangeDate',
988
+ 'cancelEmailAddress',
742
989
  'recoverSecret',
743
990
  'recoverSecretDate',
744
- 'password',
745
991
  'salt',
992
+ 'password',
993
+ 'passwordTemp',
994
+ // 'revokeTokenDate',
995
+ 'totpSecret',
746
996
  ];
747
997
  }
998
+
999
+ if (
1000
+ user != null &&
1001
+ !isCurrentUser &&
1002
+ (abilities.includes('tilmeld/switch') ||
1003
+ abilities.includes('system/admin'))
1004
+ ) {
1005
+ // Users with this ability can switch to other users.
1006
+ this.$clientEnabledMethods.push('$switchUser');
1007
+ }
1008
+ }
1009
+
1010
+ public $updateGroupDataProtection() {
1011
+ if (this.$data.group != null && !this.$data.group.$asleep()) {
1012
+ this.$data.group.$updateDataProtection();
1013
+ }
1014
+ for (let group of this.$data.groups ?? []) {
1015
+ if (!group.$asleep()) {
1016
+ group.$updateDataProtection();
1017
+ }
1018
+ }
748
1019
  }
749
1020
 
750
1021
  /**
@@ -761,35 +1032,43 @@ export default class User extends AbleObject<UserData> {
761
1032
  * @param ability The ability.
762
1033
  * @returns True or false.
763
1034
  */
764
- public $gatekeeper(ability?: string): boolean {
765
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1035
+ public async $gatekeeper(ability?: string): Promise<boolean> {
1036
+ await this.$wake();
1037
+ const tilmeld = enforceTilmeld(this);
766
1038
  if (ability == null) {
767
1039
  return tilmeld.User.current()?.$is(this) ?? false;
768
1040
  }
769
1041
  // Check the cache to see if we've already checked this user.
770
1042
  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
- );
1043
+ this.$gatekeeperCache = await this.$getGatekeeperCache();
789
1044
  }
790
1045
  return (
791
1046
  (ability in this.$gatekeeperCache && !!this.$gatekeeperCache[ability]) ||
792
- !!this.$gatekeeperCache['system/admin']
1047
+ !!this.$data.abilities?.includes('system/admin')
1048
+ );
1049
+ }
1050
+
1051
+ /**
1052
+ * Build a gatekeeper cache object.
1053
+ */
1054
+ public async $getGatekeeperCache() {
1055
+ await this.$wake();
1056
+ let abilities = this.$data.abilities ?? [];
1057
+ if (this.$data.inheritAbilities) {
1058
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1059
+ for (let curGroup of this.$data.groups ?? []) {
1060
+ if (curGroup.enabled) {
1061
+ abilities = abilities.concat(curGroup.abilities ?? []);
1062
+ }
1063
+ }
1064
+ await this.$data.group?.$wake();
1065
+ const group = this.$data.group;
1066
+ if (group != null && group.cdate != null && group.enabled) {
1067
+ abilities = abilities.concat(group.abilities ?? []);
1068
+ }
1069
+ }
1070
+ return Object.fromEntries(
1071
+ abilities.map((ability) => [ability, true as true]),
793
1072
  );
794
1073
  }
795
1074
 
@@ -806,7 +1085,7 @@ export default class User extends AbleObject<UserData> {
806
1085
  * @returns True on success, false on failure.
807
1086
  */
808
1087
  public async $sendEmailVerification() {
809
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1088
+ const tilmeld = enforceTilmeld(this);
810
1089
  if (this.guid == null) {
811
1090
  return false;
812
1091
  }
@@ -818,7 +1097,7 @@ export default class User extends AbleObject<UserData> {
818
1097
 
819
1098
  if (this.$data.secret != null) {
820
1099
  const link = `${verifyUrl}?action=verify&id=${encodeURIComponent(
821
- this.guid
1100
+ this.guid,
822
1101
  )}&secret=${encodeURIComponent(this.$data.secret)}`;
823
1102
  success =
824
1103
  success &&
@@ -836,13 +1115,13 @@ export default class User extends AbleObject<UserData> {
836
1115
  verifyLink: link,
837
1116
  },
838
1117
  },
839
- this
1118
+ this,
840
1119
  ));
841
1120
  }
842
1121
 
843
1122
  if (this.$data.newEmailSecret != null) {
844
1123
  const link = `${verifyUrl}?action=verifychange&id=${encodeURIComponent(
845
- this.guid
1124
+ this.guid,
846
1125
  )}&secret=${encodeURIComponent(this.$data.newEmailSecret)}`;
847
1126
  success =
848
1127
  success &&
@@ -862,13 +1141,13 @@ export default class User extends AbleObject<UserData> {
862
1141
  newEmail: this.$data.newEmailAddress,
863
1142
  },
864
1143
  },
865
- this
1144
+ this,
866
1145
  ));
867
1146
  }
868
1147
 
869
1148
  if (this.$data.cancelEmailSecret != null) {
870
1149
  const link = `${verifyUrl}?action=cancelchange&id=${encodeURIComponent(
871
- this.guid
1150
+ this.guid,
872
1151
  )}&secret=${encodeURIComponent(this.$data.cancelEmailSecret)}`;
873
1152
  success =
874
1153
  success &&
@@ -888,7 +1167,7 @@ export default class User extends AbleObject<UserData> {
888
1167
  newEmail: this.$data.newEmailAddress ?? this.$data.email,
889
1168
  },
890
1169
  },
891
- this
1170
+ this,
892
1171
  ));
893
1172
  }
894
1173
 
@@ -901,13 +1180,16 @@ export default class User extends AbleObject<UserData> {
901
1180
  * @param group The group.
902
1181
  * @returns True if the user is already in the group. The resulting array of groups if the user was not.
903
1182
  */
904
- public $addGroup(group: Group & GroupData) {
905
- if (this.$data.groups == null) {
906
- this.$data.groups = [];
1183
+ public async $addGroup(group: Group & GroupData) {
1184
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1185
+ let groups = this.$data.groups;
1186
+ if (groups == null) {
1187
+ groups = [];
907
1188
  }
908
- if (!group.$inArray(this.$data.groups)) {
909
- this.$data.groups.push(group);
910
- return this.$data.groups;
1189
+ if (!group.$inArray(groups)) {
1190
+ groups.push(group);
1191
+ this.$data.groups = groups;
1192
+ return groups;
911
1193
  }
912
1194
  return true;
913
1195
  }
@@ -919,7 +1201,7 @@ export default class User extends AbleObject<UserData> {
919
1201
  * @returns True if the passwords match, otherwise false.
920
1202
  */
921
1203
  public $checkPassword(password: string) {
922
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1204
+ const tilmeld = enforceTilmeld(this);
923
1205
  switch (tilmeld.config.pwMethod) {
924
1206
  case 'plain':
925
1207
  return this.$data.password === password;
@@ -940,10 +1222,12 @@ export default class User extends AbleObject<UserData> {
940
1222
  * @param group The group.
941
1223
  * @returns True if the user wasn't in the group. The resulting array of groups if the user was.
942
1224
  */
943
- public $delGroup(group: Group & GroupData) {
944
- if (this.$data.groups != null && group.$inArray(this.$data.groups)) {
1225
+ public async $delGroup(group: Group & GroupData) {
1226
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1227
+ let groups = this.$data.groups || [];
1228
+ if (this.$data.groups != null && group.$inArray(groups)) {
945
1229
  const newGroups: (Group & GroupData)[] = [];
946
- for (let curGroup of this.$data.groups) {
1230
+ for (let curGroup of groups) {
947
1231
  if (!group.$is(curGroup)) {
948
1232
  newGroups.push(curGroup);
949
1233
  }
@@ -960,7 +1244,7 @@ export default class User extends AbleObject<UserData> {
960
1244
  * @param group The group, or the group's GUID.
961
1245
  * @returns True or false.
962
1246
  */
963
- public $inGroup(group: (Group & GroupData) | string) {
1247
+ public async $inGroup(group: (Group & GroupData) | string) {
964
1248
  if (typeof group === 'string') {
965
1249
  if (this.$data.group?.guid === group) {
966
1250
  return true;
@@ -983,16 +1267,19 @@ export default class User extends AbleObject<UserData> {
983
1267
  * @param group The group, or the group's GUID.
984
1268
  * @returns True or false.
985
1269
  */
986
- public $isDescendant(group: (Group & GroupData) | string) {
1270
+ public async $isDescendant(group: (Group & GroupData) | string) {
987
1271
  // Check to see if the user is in a descendant group of the given group.
1272
+ await this.$data.group?.$wake();
988
1273
  if (
1274
+ this.$data.group != null &&
989
1275
  this.$data.group?.cdate != null &&
990
- this.$data.group.$isDescendant(group)
1276
+ (await this.$data.group?.$isDescendant(group))
991
1277
  ) {
992
1278
  return true;
993
1279
  }
1280
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
994
1281
  for (let curGroup of this.$data.groups ?? []) {
995
- if (curGroup.$isDescendant(group)) {
1282
+ if (await curGroup.$isDescendant(group)) {
996
1283
  return true;
997
1284
  }
998
1285
  }
@@ -1008,15 +1295,31 @@ export default class User extends AbleObject<UserData> {
1008
1295
  public async $changePassword(data: {
1009
1296
  newPassword: string;
1010
1297
  currentPassword: string;
1298
+ revokeCurrentTokens?: boolean;
1011
1299
  }): Promise<{ result: boolean; message: string }> {
1300
+ this.$nymph.config.debugLog(
1301
+ 'tilmeld',
1302
+ `Password change request for "${this.$data.username}".`,
1303
+ );
1012
1304
  if (!('newPassword' in data) || !data.newPassword.length) {
1013
1305
  return { result: false, message: 'Please specify a password.' };
1014
1306
  }
1015
1307
  if (!this.$checkPassword(data.currentPassword ?? '')) {
1308
+ this.$nymph.config.debugLog(
1309
+ 'tilmeld',
1310
+ `Incorrect password for change request for "${this.$data.username}".`,
1311
+ );
1016
1312
  return { result: false, message: 'Incorrect password.' };
1017
1313
  }
1018
1314
  this.$data.passwordTemp = data.newPassword;
1315
+ if (data.revokeCurrentTokens) {
1316
+ this.$data.revokeTokenDate = Date.now();
1317
+ }
1019
1318
  if (await this.$save()) {
1319
+ if (data.revokeCurrentTokens) {
1320
+ const tilmeld = enforceTilmeld(this);
1321
+ await tilmeld.login(this, true);
1322
+ }
1020
1323
  return { result: true, message: 'Your password has been changed.' };
1021
1324
  } else {
1022
1325
  return { result: false, message: "Couldn't save new password." };
@@ -1030,7 +1333,7 @@ export default class User extends AbleObject<UserData> {
1030
1333
  * @returns The resulting password or hash which is stored in the entity.
1031
1334
  */
1032
1335
  public $password(password: string) {
1033
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1336
+ const tilmeld = enforceTilmeld(this);
1034
1337
  switch (tilmeld.config.pwMethod) {
1035
1338
  case 'plain':
1036
1339
  delete this.$data.salt;
@@ -1044,7 +1347,7 @@ export default class User extends AbleObject<UserData> {
1044
1347
  default:
1045
1348
  this.$data.salt = nanoid();
1046
1349
  this.$data.password = sha256(password + this.$data.salt).toString(
1047
- Base64
1350
+ Base64,
1048
1351
  );
1049
1352
  break;
1050
1353
  }
@@ -1052,39 +1355,261 @@ export default class User extends AbleObject<UserData> {
1052
1355
  }
1053
1356
 
1054
1357
  /**
1055
- * Check that a username is valid.
1358
+ * A frontend accessible method to revoke all currently issued tokens.
1056
1359
  *
1360
+ * @param data The input data from the client.
1057
1361
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1058
1362
  */
1059
- public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1060
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1061
- if (!tilmeld.config.emailUsernames) {
1062
- if (this.$data.username == null || !this.$data.username.length) {
1063
- return { result: false, message: 'Please specify a username.' };
1064
- }
1065
- if (
1066
- tilmeld.config.maxUsernameLength > 0 &&
1067
- this.$data.username.length > tilmeld.config.maxUsernameLength
1068
- ) {
1069
- return {
1070
- result: false,
1071
- message:
1072
- 'Usernames must not exceed ' +
1073
- tilmeld.config.maxUsernameLength +
1074
- ' characters.',
1075
- };
1076
- }
1363
+ public async $revokeCurrentTokens(data: {
1364
+ password: string;
1365
+ }): Promise<{ result: boolean; message: string }> {
1366
+ if (!this.$checkPassword(data.password ?? '')) {
1367
+ return { result: false, message: 'Incorrect password.' };
1368
+ }
1077
1369
 
1078
- if (
1079
- difference(
1080
- this.$data.username.split(''),
1081
- tilmeld.config.validChars.split('')
1082
- ).length
1083
- ) {
1084
- return {
1085
- result: false,
1086
- message: tilmeld.config.validCharsNotice,
1087
- };
1370
+ this.$data.revokeTokenDate = Date.now();
1371
+
1372
+ if (await this.$save()) {
1373
+ const tilmeld = enforceTilmeld(this);
1374
+ await tilmeld.login(this, true);
1375
+ return {
1376
+ result: true,
1377
+ message: 'You have logged out of all other sessions.',
1378
+ };
1379
+ } else {
1380
+ return { result: false, message: "Couldn't save revocation date." };
1381
+ }
1382
+ }
1383
+
1384
+ public async $hasTOTPSecret() {
1385
+ return this.$data.totpSecret != null;
1386
+ }
1387
+
1388
+ /**
1389
+ * Check the given code against the user's TOTP secret.
1390
+ *
1391
+ * @param code The code in question.
1392
+ * @returns True if the code is valid, otherwise false.
1393
+ */
1394
+ public $checkTOTPCode(code: string) {
1395
+ if (this.$data.totpSecret == null) {
1396
+ return false;
1397
+ }
1398
+
1399
+ const tilmeld = enforceTilmeld(this);
1400
+
1401
+ const totp = new TOTP({
1402
+ issuer: tilmeld.config.appName,
1403
+ label: this.$data.username,
1404
+ algorithm: 'SHA1',
1405
+ digits: 6,
1406
+ period: 30,
1407
+ secret: this.$data.totpSecret,
1408
+ });
1409
+
1410
+ return totp.validate({ token: code, window: 2 }) != null;
1411
+ }
1412
+
1413
+ /**
1414
+ * A frontend accessible method to generate a new TOTP secret.
1415
+ *
1416
+ * @returns An object with 'uri', 'qrcode', and 'secret'.
1417
+ */
1418
+ public async $getNewTOTPSecret() {
1419
+ const tilmeld = enforceTilmeld(this);
1420
+
1421
+ if (this.$data.totpSecret != null) {
1422
+ throw new BadDataError('You already have a 2FA secret.');
1423
+ }
1424
+
1425
+ const secret = new Secret();
1426
+
1427
+ const totp = new TOTP({
1428
+ issuer: tilmeld.config.appName,
1429
+ label: this.$data.username,
1430
+ algorithm: 'SHA1',
1431
+ digits: 6,
1432
+ period: 30,
1433
+ secret,
1434
+ });
1435
+
1436
+ const uri = totp.toString();
1437
+
1438
+ const qrcode = await toDataURL(uri);
1439
+
1440
+ return {
1441
+ uri,
1442
+ qrcode,
1443
+ secret: secret.base32,
1444
+ };
1445
+ }
1446
+
1447
+ /**
1448
+ * A frontend accessible method to save a TOTP secret into the user's account.
1449
+ *
1450
+ * @param data The input data from the client.
1451
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1452
+ */
1453
+ public async $saveTOTPSecret(data: {
1454
+ password: string;
1455
+ secret: string;
1456
+ code: string;
1457
+ }): Promise<{ result: boolean; message: string }> {
1458
+ if (!this.$checkPassword(data.password ?? '')) {
1459
+ return { result: false, message: 'Incorrect password.' };
1460
+ }
1461
+
1462
+ if (this.$data.totpSecret != null) {
1463
+ return { result: false, message: 'You already have a 2FA secret.' };
1464
+ }
1465
+
1466
+ if (
1467
+ data.secret == null ||
1468
+ typeof data.secret !== 'string' ||
1469
+ data.secret === ''
1470
+ ) {
1471
+ return { result: false, message: '2FA secret is invalid.' };
1472
+ }
1473
+
1474
+ if (
1475
+ data.code == null ||
1476
+ typeof data.code !== 'string' ||
1477
+ data.code.length !== 6
1478
+ ) {
1479
+ return { result: false, message: '2FA code is invalid.' };
1480
+ }
1481
+
1482
+ const tilmeld = enforceTilmeld(this);
1483
+
1484
+ const totp = new TOTP({
1485
+ issuer: tilmeld.config.appName,
1486
+ label: this.$data.username,
1487
+ algorithm: 'SHA1',
1488
+ digits: 6,
1489
+ period: 30,
1490
+ secret: data.secret,
1491
+ });
1492
+
1493
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1494
+ return { result: false, message: '2FA code is incorrect.' };
1495
+ }
1496
+
1497
+ this.$data.totpSecret = data.secret;
1498
+
1499
+ if (await this.$save()) {
1500
+ return { result: true, message: 'Your two factor secret is now set.' };
1501
+ } else {
1502
+ return { result: false, message: "Couldn't save two factor secret." };
1503
+ }
1504
+ }
1505
+
1506
+ /**
1507
+ * A frontend accessible method to remove the TOTP secret from the user's
1508
+ * account.
1509
+ *
1510
+ * @param data The input data from the client.
1511
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1512
+ */
1513
+ public async $removeTOTPSecret(data?: {
1514
+ password: string;
1515
+ code: string;
1516
+ }): Promise<{ result: boolean; message: string }> {
1517
+ const tilmeld = enforceTilmeld(this);
1518
+
1519
+ if (data) {
1520
+ if (!this.$checkPassword(data.password ?? '')) {
1521
+ return { result: false, message: 'Incorrect password.' };
1522
+ }
1523
+
1524
+ if (
1525
+ data.code == null ||
1526
+ typeof data.code !== 'string' ||
1527
+ data.code.length !== 6
1528
+ ) {
1529
+ return { result: false, message: '2FA code is invalid.' };
1530
+ }
1531
+
1532
+ if (this.$data.totpSecret == null) {
1533
+ return { result: false, message: "You don't have a 2FA secret." };
1534
+ }
1535
+
1536
+ const totp = new TOTP({
1537
+ issuer: tilmeld.config.appName,
1538
+ label: this.$data.username,
1539
+ algorithm: 'SHA1',
1540
+ digits: 6,
1541
+ period: 30,
1542
+ secret: this.$data.totpSecret,
1543
+ });
1544
+
1545
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1546
+ return { result: false, message: '2FA code is incorrect.' };
1547
+ }
1548
+ } else if (!tilmeld.gatekeeper('tilmeld/admin')) {
1549
+ return {
1550
+ result: false,
1551
+ message: 'You must provide your password and 2FA code.',
1552
+ };
1553
+ }
1554
+
1555
+ delete this.$data.totpSecret;
1556
+
1557
+ if (await this.$save()) {
1558
+ return {
1559
+ result: true,
1560
+ message: 'Two factor secret has been removed.',
1561
+ };
1562
+ } else {
1563
+ return { result: false, message: "Couldn't remove two factor secret." };
1564
+ }
1565
+ }
1566
+
1567
+ /**
1568
+ * Check that a username is valid.
1569
+ *
1570
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1571
+ */
1572
+ public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1573
+ const tilmeld = enforceTilmeld(this);
1574
+
1575
+ this.$nymph.config.debugInfo(
1576
+ 'tilmeld',
1577
+ `Username check "${this.$data.username}".`,
1578
+ );
1579
+
1580
+ if (!tilmeld.config.emailUsernames) {
1581
+ if (this.$data.username == null || !this.$data.username.length) {
1582
+ return { result: false, message: 'Please specify a username.' };
1583
+ }
1584
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1585
+ return {
1586
+ result: false,
1587
+ message:
1588
+ 'Usernames must be at least ' +
1589
+ tilmeld.config.minUsernameLength +
1590
+ ' characters.',
1591
+ };
1592
+ }
1593
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1594
+ return {
1595
+ result: false,
1596
+ message:
1597
+ 'Usernames must not exceed ' +
1598
+ tilmeld.config.maxUsernameLength +
1599
+ ' characters.',
1600
+ };
1601
+ }
1602
+
1603
+ if (
1604
+ difference(
1605
+ this.$data.username.split(''),
1606
+ tilmeld.config.validChars.split(''),
1607
+ ).length
1608
+ ) {
1609
+ return {
1610
+ result: false,
1611
+ message: tilmeld.config.validCharsNotice,
1612
+ };
1088
1613
  }
1089
1614
  if (!tilmeld.config.validRegex.test(this.$data.username)) {
1090
1615
  return {
@@ -1105,12 +1630,24 @@ export default class User extends AbleObject<UserData> {
1105
1630
  }
1106
1631
  const test = await this.$nymph.getEntity(
1107
1632
  { class: tilmeld.User, skipAc: true },
1108
- selector
1633
+ selector,
1109
1634
  );
1110
1635
  if (test != null) {
1111
1636
  return { result: false, message: 'That username is taken.' };
1112
1637
  }
1113
1638
 
1639
+ for (let callback of (this.constructor as typeof User)
1640
+ .checkUsernameCallbacks) {
1641
+ if (callback) {
1642
+ const result = await callback(this, {
1643
+ username: this.$data.username,
1644
+ });
1645
+ if (!result.result) {
1646
+ return { message: 'Username is not available.', ...result };
1647
+ }
1648
+ }
1649
+ }
1650
+
1114
1651
  return {
1115
1652
  result: true,
1116
1653
  message:
@@ -1121,10 +1658,16 @@ export default class User extends AbleObject<UserData> {
1121
1658
  if (this.$data.username == null || !this.$data.username.length) {
1122
1659
  return { result: false, message: 'Please specify an email.' };
1123
1660
  }
1124
- if (
1125
- tilmeld.config.maxUsernameLength > 0 &&
1126
- this.$data.username.length > tilmeld.config.maxUsernameLength
1127
- ) {
1661
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1662
+ return {
1663
+ result: false,
1664
+ message:
1665
+ 'Emails must be at least ' +
1666
+ tilmeld.config.minUsernameLength +
1667
+ ' characters.',
1668
+ };
1669
+ }
1670
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1128
1671
  return {
1129
1672
  result: false,
1130
1673
  message:
@@ -1144,7 +1687,13 @@ export default class User extends AbleObject<UserData> {
1144
1687
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1145
1688
  */
1146
1689
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
1147
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1690
+ const tilmeld = enforceTilmeld(this);
1691
+
1692
+ this.$nymph.config.debugInfo(
1693
+ 'tilmeld',
1694
+ `Email check "${this.$data.email}".`,
1695
+ );
1696
+
1148
1697
  if (this.$data.email == null || !this.$data.email.length) {
1149
1698
  if (tilmeld.config.verifyEmail) {
1150
1699
  return { result: false, message: 'Please specify an email.' };
@@ -1167,7 +1716,7 @@ export default class User extends AbleObject<UserData> {
1167
1716
  }
1168
1717
  const test = await this.$nymph.getEntity(
1169
1718
  { class: tilmeld.User, skipAc: true },
1170
- selector
1719
+ selector,
1171
1720
  );
1172
1721
  if (test != null) {
1173
1722
  return {
@@ -1189,7 +1738,13 @@ export default class User extends AbleObject<UserData> {
1189
1738
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1190
1739
  */
1191
1740
  public async $checkPhone(): Promise<{ result: boolean; message: string }> {
1192
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1741
+ const tilmeld = enforceTilmeld(this);
1742
+
1743
+ this.$nymph.config.debugInfo(
1744
+ 'tilmeld',
1745
+ `Phone check "${this.$data.phone}".`,
1746
+ );
1747
+
1193
1748
  if (this.$data.phone == null || !this.$data.phone.length) {
1194
1749
  return { result: false, message: 'Please specify a phone number.' };
1195
1750
  }
@@ -1211,7 +1766,7 @@ export default class User extends AbleObject<UserData> {
1211
1766
  }
1212
1767
  const test = await this.$nymph.getEntity(
1213
1768
  { class: tilmeld.User, skipAc: true },
1214
- selector
1769
+ selector,
1215
1770
  );
1216
1771
  if (test != null) {
1217
1772
  return { result: false, message: 'Phone number is in use.' };
@@ -1228,7 +1783,7 @@ export default class User extends AbleObject<UserData> {
1228
1783
  password: string;
1229
1784
  additionalData?: { [k: string]: any };
1230
1785
  }): Promise<{ result: boolean; loggedin: boolean; message: string }> {
1231
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
1786
+ let tilmeld = enforceTilmeld(this);
1232
1787
  if (!tilmeld.config.allowRegistration) {
1233
1788
  return {
1234
1789
  result: false,
@@ -1244,21 +1799,6 @@ export default class User extends AbleObject<UserData> {
1244
1799
  };
1245
1800
  }
1246
1801
 
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
1802
  if (!('password' in data) || !data.password.length) {
1263
1803
  return {
1264
1804
  result: false,
@@ -1272,7 +1812,7 @@ export default class User extends AbleObject<UserData> {
1272
1812
  }
1273
1813
 
1274
1814
  this.$password(data.password);
1275
- if (tilmeld.config.regFields.indexOf('name') !== -1) {
1815
+ if (tilmeld.config.regFields.includes('name')) {
1276
1816
  this.$data.name =
1277
1817
  this.$data.nameFirst +
1278
1818
  (this.$data.nameMiddle == null ? '' : ' ' + this.$data.nameMiddle) +
@@ -1289,75 +1829,34 @@ export default class User extends AbleObject<UserData> {
1289
1829
  const transaction = 'tilmeld-register-' + nanoid();
1290
1830
  const nymph = this.$nymph;
1291
1831
  const tnymph = await this.$nymph.startTransaction(transaction);
1292
- this.$nymph = tnymph;
1293
- tilmeld = this.$nymph.tilmeld as Tilmeld;
1832
+ this.$setNymph(tnymph);
1833
+ tilmeld = enforceTilmeld(this);
1834
+
1835
+ let message = '';
1836
+ let loggedin = false;
1294
1837
 
1295
1838
  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],
1839
+ try {
1840
+ for (let callback of (this.constructor as typeof User)
1841
+ .beforeRegisterCallbacks) {
1842
+ if (callback) {
1843
+ await callback(this, data);
1332
1844
  }
1333
- );
1334
- if (group != null) {
1335
- this.$data.group = group;
1336
1845
  }
1846
+ } catch (e: any) {
1847
+ await tnymph.rollback(transaction);
1848
+ this.$setNymph(nymph);
1849
+ return {
1850
+ result: false,
1851
+ loggedin: false,
1852
+ message: e.message,
1853
+ };
1337
1854
  }
1338
1855
 
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) {
1856
+ if (
1857
+ tilmeld.config.userFields.includes('email') &&
1858
+ tilmeld.config.verifyEmail
1859
+ ) {
1361
1860
  // The user will be enabled after verifying their e-mail address.
1362
1861
  if (!tilmeld.config.unverifiedAccess) {
1363
1862
  this.$data.enabled = false;
@@ -1366,7 +1865,7 @@ export default class User extends AbleObject<UserData> {
1366
1865
  this.$data.enabled = true;
1367
1866
  }
1368
1867
 
1369
- // If create_admin is true and there are no other users, grant
1868
+ // If createAdmin is true and there are no other users, grant
1370
1869
  // "system/admin".
1371
1870
  let madeAdmin = false;
1372
1871
  if (tilmeld.config.createAdmin) {
@@ -1384,7 +1883,15 @@ export default class User extends AbleObject<UserData> {
1384
1883
  }
1385
1884
  }
1386
1885
 
1886
+ this.$nymph.config.debugLog(
1887
+ 'tilmeld',
1888
+ `Registering new user "${this.$data.username}".`,
1889
+ );
1387
1890
  if (await this.$saveSkipAC()) {
1891
+ this.$nymph.config.debugLog(
1892
+ 'tilmeld',
1893
+ `New user registered "${this.$data.username}".`,
1894
+ );
1388
1895
  // Send the new user registered email.
1389
1896
  if (tilmeld.config.userRegisteredRecipient != null) {
1390
1897
  await tilmeld.config.sendEmail(
@@ -1403,33 +1910,16 @@ export default class User extends AbleObject<UserData> {
1403
1910
  userPhone: this.$data.phone,
1404
1911
  },
1405
1912
  },
1406
- this
1913
+ this,
1407
1914
  );
1408
1915
  }
1409
1916
 
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
1917
  // Finish up.
1429
1918
  if (
1430
1919
  tilmeld.config.verifyEmail &&
1431
1920
  !tilmeld.config.unverifiedAccess &&
1432
- !madeAdmin
1921
+ !madeAdmin &&
1922
+ tilmeld.config.userFields.includes('email')
1433
1923
  ) {
1434
1924
  message +=
1435
1925
  `Almost there. An email has been sent to ${this.$data.email} ` +
@@ -1437,9 +1927,10 @@ export default class User extends AbleObject<UserData> {
1437
1927
  } else if (
1438
1928
  tilmeld.config.verifyEmail &&
1439
1929
  tilmeld.config.unverifiedAccess &&
1440
- !madeAdmin
1930
+ !madeAdmin &&
1931
+ tilmeld.config.userFields.includes('email')
1441
1932
  ) {
1442
- if (!tilmeld.login(this, true)) {
1933
+ if (!(await tilmeld.login(this, true))) {
1443
1934
  throw new Error('An error occurred trying to log you in.');
1444
1935
  }
1445
1936
  message +=
@@ -1448,7 +1939,7 @@ export default class User extends AbleObject<UserData> {
1448
1939
  'registration.';
1449
1940
  loggedin = true;
1450
1941
  } else {
1451
- if (!tilmeld.login(this, true)) {
1942
+ if (!(await tilmeld.login(this, true))) {
1452
1943
  throw new Error('An error occurred trying to log you in.');
1453
1944
  }
1454
1945
  message += "You're now registered and logged in!";
@@ -1464,23 +1955,13 @@ export default class User extends AbleObject<UserData> {
1464
1955
  });
1465
1956
  }
1466
1957
  }
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
1958
  } else {
1959
+ this.$nymph.config.debugError(
1960
+ 'tilmeld',
1961
+ `Error registering new user "${this.$data.username}".`,
1962
+ );
1482
1963
  await tnymph.rollback(transaction);
1483
- this.$nymph = nymph;
1964
+ this.$setNymph(nymph);
1484
1965
  return {
1485
1966
  result: false,
1486
1967
  loggedin: false,
@@ -1488,25 +1969,44 @@ export default class User extends AbleObject<UserData> {
1488
1969
  };
1489
1970
  }
1490
1971
  } catch (e: any) {
1972
+ this.$nymph.config.debugError(
1973
+ 'tilmeld',
1974
+ `Error registering new user "${this.$data.username}": ${e}`,
1975
+ );
1491
1976
  await tnymph.rollback(transaction);
1492
- this.$nymph = nymph;
1977
+ this.$setNymph(nymph);
1978
+ throw e;
1979
+ }
1980
+
1981
+ try {
1982
+ await tnymph.commit(transaction);
1983
+ this.$setNymph(nymph);
1984
+ await this.$nymph.tilmeld?.fillSession(this);
1985
+ } catch (e: any) {
1493
1986
  throw e;
1494
1987
  }
1988
+
1989
+ return {
1990
+ result: true,
1991
+ loggedin,
1992
+ message,
1993
+ };
1495
1994
  }
1496
1995
 
1497
1996
  public async $save() {
1498
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
1997
+ let tilmeld = enforceTilmeld(this);
1499
1998
  if (this.$data.username == null || !this.$data.username.trim().length) {
1500
1999
  return false;
1501
2000
  }
1502
2001
 
1503
2002
  if (
2003
+ !this.$skipAcWhenSaving &&
1504
2004
  tilmeld.gatekeeper('tilmeld/admin') &&
1505
2005
  !tilmeld.gatekeeper('system/admin') &&
1506
- this.$gatekeeper('system/admin')
2006
+ this.$data.abilities?.includes('system/admin')
1507
2007
  ) {
1508
2008
  throw new BadDataError(
1509
- "You don't have the authority to modify system admins."
2009
+ "You don't have the authority to modify system admins.",
1510
2010
  );
1511
2011
  }
1512
2012
 
@@ -1528,49 +2028,88 @@ export default class User extends AbleObject<UserData> {
1528
2028
  this.$data.enabled = !!this.$data.enabled;
1529
2029
 
1530
2030
  // Clear empty values.
1531
- if (this.$data.nameFirst === '') {
2031
+ if (
2032
+ this.$data.nameFirst === '' ||
2033
+ !tilmeld.config.userFields.includes('name')
2034
+ ) {
1532
2035
  delete this.$data.nameFirst;
1533
2036
  }
1534
- if (this.$data.nameMiddle === '') {
2037
+ if (
2038
+ this.$data.nameMiddle === '' ||
2039
+ !tilmeld.config.userFields.includes('name')
2040
+ ) {
1535
2041
  delete this.$data.nameMiddle;
1536
2042
  }
1537
- if (this.$data.nameLast === '') {
2043
+ if (
2044
+ this.$data.nameLast === '' ||
2045
+ !tilmeld.config.userFields.includes('name')
2046
+ ) {
1538
2047
  delete this.$data.nameLast;
1539
2048
  }
1540
- if (this.$data.name === '') {
2049
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
1541
2050
  delete this.$data.name;
1542
2051
  }
1543
- if (this.$data.email === '') {
2052
+ if (
2053
+ this.$data.email === '' ||
2054
+ !tilmeld.config.userFields.includes('email')
2055
+ ) {
1544
2056
  delete this.$data.email;
1545
2057
  }
1546
2058
  if (this.$data.avatar === '') {
1547
2059
  delete this.$data.avatar;
1548
2060
  }
1549
- if (this.$data.phone === '') {
2061
+ if (
2062
+ this.$data.phone === '' ||
2063
+ !tilmeld.config.userFields.includes('phone')
2064
+ ) {
1550
2065
  delete this.$data.phone;
1551
2066
  }
1552
- if (this.$data.secret === '') {
2067
+ if (
2068
+ this.$data.secret === '' ||
2069
+ !tilmeld.config.userFields.includes('email')
2070
+ ) {
1553
2071
  delete this.$data.secret;
1554
2072
  }
1555
- if (this.$data.emailChangeDate === 0) {
2073
+ if (
2074
+ this.$data.emailChangeDate === 0 ||
2075
+ !tilmeld.config.userFields.includes('email')
2076
+ ) {
1556
2077
  delete this.$data.emailChangeDate;
1557
2078
  }
1558
- if (this.$data.newEmailSecret === '') {
2079
+ if (
2080
+ this.$data.newEmailSecret === '' ||
2081
+ !tilmeld.config.userFields.includes('email')
2082
+ ) {
1559
2083
  delete this.$data.newEmailSecret;
1560
2084
  }
1561
- if (this.$data.newEmailAddress === '') {
2085
+ if (
2086
+ this.$data.newEmailAddress === '' ||
2087
+ !tilmeld.config.userFields.includes('email')
2088
+ ) {
1562
2089
  delete this.$data.newEmailAddress;
1563
2090
  }
1564
- if (this.$data.cancelEmailSecret === '') {
2091
+ if (
2092
+ this.$data.cancelEmailSecret === '' ||
2093
+ !tilmeld.config.userFields.includes('email')
2094
+ ) {
1565
2095
  delete this.$data.cancelEmailSecret;
1566
2096
  }
1567
- if (this.$data.cancelEmailAddress === '') {
2097
+ if (
2098
+ this.$data.cancelEmailAddress === '' ||
2099
+ !tilmeld.config.userFields.includes('email')
2100
+ ) {
1568
2101
  delete this.$data.cancelEmailAddress;
1569
2102
  }
1570
- if (this.$data.recoverSecret === '') {
2103
+ if (
2104
+ this.$data.recoverSecret === '' ||
2105
+ !tilmeld.config.userFields.includes('email')
2106
+ ) {
1571
2107
  delete this.$data.recoverSecret;
1572
2108
  }
1573
- if (this.$data.recoverSecretDate === 0) {
2109
+ if (
2110
+ this.$data.recoverSecretDate === 0 ||
2111
+ !tilmeld.config.userFields.includes('email')
2112
+ ) {
1574
2113
  delete this.$data.recoverSecretDate;
1575
2114
  }
1576
2115
  if (this.$data.password === '') {
@@ -1588,7 +2127,10 @@ export default class User extends AbleObject<UserData> {
1588
2127
  if (!unCheck.result) {
1589
2128
  throw new BadUsernameError(unCheck.message);
1590
2129
  }
1591
- if (!tilmeld.config.emailUsernames) {
2130
+ if (
2131
+ !tilmeld.config.emailUsernames &&
2132
+ tilmeld.config.userFields.includes('email')
2133
+ ) {
1592
2134
  const emCheck = await this.$checkEmail();
1593
2135
  if (!emCheck.result) {
1594
2136
  throw new BadEmailError(emCheck.message);
@@ -1596,7 +2138,10 @@ export default class User extends AbleObject<UserData> {
1596
2138
  }
1597
2139
 
1598
2140
  // Email changes.
1599
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
2141
+ if (
2142
+ !tilmeld.gatekeeper('tilmeld/admin') &&
2143
+ tilmeld.config.userFields.includes('email')
2144
+ ) {
1600
2145
  // The user isn't an admin, so email address changes should contain some
1601
2146
  // security measures.
1602
2147
  if (tilmeld.config.verifyEmail) {
@@ -1604,7 +2149,7 @@ export default class User extends AbleObject<UserData> {
1604
2149
  if (this.guid == null) {
1605
2150
  // If this is the first user, they'll be made an admin, so they don't
1606
2151
  // need to verify.
1607
- if ((this.$data.abilities?.indexOf('system/admin') ?? -1) === -1) {
2152
+ if (!this.$data.abilities?.includes('system/admin')) {
1608
2153
  this.$data.secret = nanoid();
1609
2154
  sendVerification = true;
1610
2155
  }
@@ -1624,10 +2169,10 @@ export default class User extends AbleObject<UserData> {
1624
2169
  new Date(
1625
2170
  strtotime(
1626
2171
  '+' + tilmeld.config.emailRateLimit,
1627
- Math.floor(this.$data.emailChangeDate / 1000)
1628
- ) * 1000
2172
+ Math.floor(this.$data.emailChangeDate / 1000),
2173
+ ) * 1000,
1629
2174
  ).toString() +
1630
- ' to change your email address again.'
2175
+ ' to change your email address again.',
1631
2176
  );
1632
2177
  } else {
1633
2178
  if (
@@ -1682,37 +2227,129 @@ export default class User extends AbleObject<UserData> {
1682
2227
  throw new BadDataError('A password is required.');
1683
2228
  }
1684
2229
 
1685
- try {
1686
- tilmeld.config.validatorUser(this);
1687
- } catch (e: any) {
1688
- throw new BadDataError(e?.message);
1689
- }
1690
-
1691
2230
  // Start transaction.
1692
2231
  const transaction = 'tilmeld-save-' + nanoid();
1693
2232
  const nymph = this.$nymph;
1694
2233
  const tnymph = await this.$nymph.startTransaction(transaction);
1695
- this.$nymph = tnymph;
1696
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2234
+ this.$setNymph(tnymph);
2235
+ tilmeld = enforceTilmeld(this);
1697
2236
 
1698
- if (
1699
- this.$data.group != null &&
1700
- this.$data.group.user != null &&
1701
- this.$is(this.$data.group.user)
1702
- ) {
1703
- try {
2237
+ // If the primary group is generated now, the user assigned to it won't have
2238
+ // a guid yet, so it needs to be assigned after the user is saved.
2239
+ let saveGeneratedPrimaryGroupAgain = false;
2240
+
2241
+ try {
2242
+ let group = this.$data.group;
2243
+ await group?.$wake();
2244
+ if (group == null && this.guid == null) {
2245
+ if (tilmeld.config.generatePrimary) {
2246
+ // Generate a new primary group for the user.
2247
+ group = await tilmeld.Group.factory();
2248
+ // This user doesn't have a guid yet, so that's why we set
2249
+ // saveGeneratedPrimaryGroupAgain.
2250
+ group.user = this;
2251
+ saveGeneratedPrimaryGroupAgain = true;
2252
+ const parent = await tnymph.getEntity(
2253
+ { class: tilmeld.Group },
2254
+ {
2255
+ type: '&',
2256
+ equal: ['defaultPrimary', true],
2257
+ },
2258
+ );
2259
+ if (parent != null) {
2260
+ group.parent = parent;
2261
+ }
2262
+ } else {
2263
+ // Add the default primary.
2264
+ const group = await tnymph.getEntity(
2265
+ { class: tilmeld.Group },
2266
+ {
2267
+ type: '&',
2268
+ equal: ['defaultPrimary', true],
2269
+ },
2270
+ );
2271
+ if (group != null) {
2272
+ this.$data.group = group;
2273
+ }
2274
+ }
2275
+ }
2276
+ if (group != null && group.user != null && this.$is(group.user)) {
1704
2277
  // 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();
2278
+ let changed = false;
2279
+ if (group.groupname !== this.$data.username) {
2280
+ group.groupname = this.$data.username;
2281
+ changed = true;
2282
+ }
2283
+ if (group.avatar !== this.$data.avatar) {
2284
+ group.avatar = this.$data.avatar;
2285
+ changed = true;
2286
+ }
2287
+ if (
2288
+ tilmeld.config.userFields.includes('email') &&
2289
+ group.email !== this.$data.email
2290
+ ) {
2291
+ group.email = this.$data.email;
2292
+ changed = true;
2293
+ }
2294
+ if (
2295
+ tilmeld.config.userFields.includes('name') &&
2296
+ group.name !== this.$data.name
2297
+ ) {
2298
+ group.name = this.$data.name;
2299
+ changed = true;
2300
+ }
2301
+ if (
2302
+ tilmeld.config.userFields.includes('phone') &&
2303
+ group.phone !== this.$data.phone
2304
+ ) {
2305
+ group.phone = this.$data.phone;
2306
+ changed = true;
2307
+ }
2308
+ if (changed) {
2309
+ if (!(await group.$saveSkipAC())) {
2310
+ throw Error('Error updating primary group for user.');
2311
+ }
2312
+ }
2313
+ this.$data.group = group;
2314
+ }
2315
+
2316
+ if (this.$data.groups == null || this.$data.groups.length === 0) {
2317
+ // Add secondary groups.
2318
+ if (
2319
+ tilmeld.config.userFields.includes('email') &&
2320
+ tilmeld.config.verifyEmail &&
2321
+ tilmeld.config.unverifiedAccess &&
2322
+ this.$data.secret != null
2323
+ ) {
2324
+ // Add the default secondaries for unverified users.
2325
+ this.$data.groups = await tnymph.getEntities(
2326
+ { class: tilmeld.Group },
2327
+ {
2328
+ type: '&',
2329
+ equal: ['unverifiedSecondary', true],
2330
+ },
2331
+ );
2332
+ } else {
2333
+ // Add the default secondaries.
2334
+ this.$data.groups = await tnymph.getEntities(
2335
+ { class: tilmeld.Group },
2336
+ {
2337
+ type: '&',
2338
+ equal: ['defaultSecondary', true],
2339
+ },
2340
+ );
2341
+ }
2342
+ }
2343
+
2344
+ try {
2345
+ tilmeld.config.validatorUser(tilmeld, this);
1711
2346
  } catch (e: any) {
1712
- await tnymph.rollback(transaction);
1713
- this.$nymph = nymph;
1714
- throw e;
2347
+ throw new BadDataError(e?.message);
1715
2348
  }
2349
+ } catch (e: any) {
2350
+ await tnymph.rollback(transaction);
2351
+ this.$setNymph(nymph);
2352
+ throw e;
1716
2353
  }
1717
2354
 
1718
2355
  let ret: boolean;
@@ -1722,11 +2359,26 @@ export default class User extends AbleObject<UserData> {
1722
2359
 
1723
2360
  try {
1724
2361
  ret = await super.$save();
2362
+
2363
+ if (ret && saveGeneratedPrimaryGroupAgain) {
2364
+ const group = this.$data.group;
2365
+ if (group == null) {
2366
+ throw Error('Generated primary group not found.');
2367
+ }
2368
+ await group.$wake();
2369
+ // Now this user has a GUID, so saving it on the group will succeed.
2370
+ group.user = this;
2371
+ if (!(await group.$saveSkipAC())) {
2372
+ throw Error("Generated primary group couldn't be saved.");
2373
+ }
2374
+ this.$data.group = group;
2375
+ }
1725
2376
  } catch (e: any) {
1726
2377
  await tnymph.rollback(transaction);
1727
- this.$nymph = nymph;
2378
+ this.$setNymph(nymph);
1728
2379
  throw e;
1729
2380
  }
2381
+
1730
2382
  if (ret) {
1731
2383
  if (sendVerification) {
1732
2384
  // The email has changed, so send a new verification email.
@@ -1735,7 +2387,7 @@ export default class User extends AbleObject<UserData> {
1735
2387
  this.guid = preGuid;
1736
2388
  this.cdate = preCdate;
1737
2389
  this.mdate = preMdate;
1738
- this.$nymph = nymph;
2390
+ this.$setNymph(nymph);
1739
2391
  throw new Error("Couldn't send verification email.");
1740
2392
  }
1741
2393
  }
@@ -1744,14 +2396,16 @@ export default class User extends AbleObject<UserData> {
1744
2396
  this.$gatekeeperCache = undefined;
1745
2397
  await tnymph.commit(transaction);
1746
2398
 
1747
- if ((nymph.tilmeld as Tilmeld).User.current(true).$is(this)) {
2399
+ const tilmeld = enforceTilmeld(nymph);
2400
+
2401
+ if (tilmeld.User.current(true).$is(this)) {
1748
2402
  // Update the user in the session cache.
1749
- (nymph.tilmeld as Tilmeld).fillSession(this);
2403
+ await tilmeld.fillSession(this);
1750
2404
  }
1751
2405
  } else {
1752
2406
  await tnymph.rollback(transaction);
1753
2407
  }
1754
- this.$nymph = nymph;
2408
+ this.$setNymph(nymph);
1755
2409
  return ret;
1756
2410
  }
1757
2411
 
@@ -1772,16 +2426,17 @@ export default class User extends AbleObject<UserData> {
1772
2426
  }
1773
2427
 
1774
2428
  public async $delete() {
1775
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1776
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
2429
+ const tilmeld = enforceTilmeld(this);
2430
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
1777
2431
  throw new BadDataError("You don't have the authority to delete users.");
1778
2432
  }
1779
2433
  if (
2434
+ !this.$skipAcWhenDeleting &&
1780
2435
  !tilmeld.gatekeeper('system/admin') &&
1781
- this.$gatekeeper('system/admin')
2436
+ this.$data.abilities?.includes('system/admin')
1782
2437
  ) {
1783
2438
  throw new BadDataError(
1784
- "You don't have the authority to delete system admins."
2439
+ "You don't have the authority to delete system admins.",
1785
2440
  );
1786
2441
  }
1787
2442
  if (tilmeld.User.current(true).$is(this)) {
@@ -1790,9 +2445,27 @@ export default class User extends AbleObject<UserData> {
1790
2445
  return await super.$delete();
1791
2446
  }
1792
2447
 
2448
+ /*
2449
+ * This should *never* be accessible on the client.
2450
+ */
2451
+ async $deleteSkipAC() {
2452
+ this.$skipAcWhenDeleting = true;
2453
+ return await this.$delete();
2454
+ }
2455
+
2456
+ $tilmeldDeleteSkipAC() {
2457
+ if (this.$skipAcWhenDeleting) {
2458
+ this.$skipAcWhenDeleting = false;
2459
+ return true;
2460
+ }
2461
+ return false;
2462
+ }
2463
+
1793
2464
  public static on<T extends EventType>(
1794
2465
  event: T,
1795
- callback: T extends 'beforeRegister'
2466
+ callback: T extends 'checkUsername'
2467
+ ? TilmeldCheckUsernameCallback
2468
+ : T extends 'beforeRegister'
1796
2469
  ? TilmeldBeforeRegisterCallback
1797
2470
  : T extends 'afterRegister'
1798
2471
  ? TilmeldAfterRegisterCallback
@@ -1804,9 +2477,11 @@ export default class User extends AbleObject<UserData> {
1804
2477
  ? TilmeldBeforeLogoutCallback
1805
2478
  : T extends 'afterLogout'
1806
2479
  ? TilmeldAfterLogoutCallback
1807
- : never
2480
+ : never,
1808
2481
  ) {
1809
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
2482
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2483
+ ? 'checkUsernameCallbacks'
2484
+ : T extends 'beforeRegister'
1810
2485
  ? 'beforeRegisterCallbacks'
1811
2486
  : T extends 'afterRegister'
1812
2487
  ? 'afterRegisterCallbacks'
@@ -1829,7 +2504,9 @@ export default class User extends AbleObject<UserData> {
1829
2504
 
1830
2505
  public static off<T extends EventType>(
1831
2506
  event: T,
1832
- callback: T extends 'beforeRegister'
2507
+ callback: T extends 'checkUsername'
2508
+ ? TilmeldCheckUsernameCallback
2509
+ : T extends 'beforeRegister'
1833
2510
  ? TilmeldBeforeRegisterCallback
1834
2511
  : T extends 'afterRegister'
1835
2512
  ? TilmeldAfterRegisterCallback
@@ -1841,9 +2518,11 @@ export default class User extends AbleObject<UserData> {
1841
2518
  ? TilmeldBeforeLogoutCallback
1842
2519
  : T extends 'afterLogout'
1843
2520
  ? TilmeldAfterLogoutCallback
1844
- : never
2521
+ : never,
1845
2522
  ) {
1846
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
2523
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2524
+ ? 'checkUsernameCallbacks'
2525
+ : T extends 'beforeRegister'
1847
2526
  ? 'beforeRegisterCallbacks'
1848
2527
  : T extends 'afterRegister'
1849
2528
  ? 'afterRegisterCallbacks'