@nymphjs/tilmeld 1.0.0-beta.5 → 1.0.0-beta.50

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.
package/src/User.ts CHANGED
@@ -5,15 +5,17 @@ import {
5
5
  Selector,
6
6
  SerializedEntityData,
7
7
  } from '@nymphjs/nymph';
8
+ import { humanSecret, nanoid } from '@nymphjs/guid';
8
9
  import { EmailOptions } from 'email-templates';
9
10
  import strtotime from 'locutus/php/datetime/strtotime';
10
11
  import Base64 from 'crypto-js/enc-base64';
11
12
  import sha256 from 'crypto-js/sha256';
12
13
  import md5 from 'crypto-js/md5';
13
14
  import { difference } from 'lodash';
14
- import { humanSecret, nanoid } from '@nymphjs/guid';
15
+ import { TOTP, Secret } from 'otpauth';
16
+ import { toDataURL } from 'qrcode';
15
17
 
16
- import type Tilmeld from './Tilmeld';
18
+ import { enforceTilmeld } from './enforceTilmeld';
17
19
  import AbleObject from './AbleObject';
18
20
  import Group, { GroupData } from './Group';
19
21
  import {
@@ -24,23 +26,33 @@ import {
24
26
  } from './errors';
25
27
 
26
28
  export type EventType =
29
+ | 'checkUsername'
27
30
  | 'beforeRegister'
28
31
  | 'afterRegister'
29
32
  | 'beforeLogin'
30
33
  | 'afterLogin'
31
34
  | 'beforeLogout'
32
35
  | 'afterLogout';
36
+ /**
37
+ * This is run when the user has entered an otherwise valid username into the
38
+ * signup form. It should return a result, which when false will stop the
39
+ * process and return the included message, disallowing the username.
40
+ */
41
+ export type TilmeldCheckUsernameCallback = (
42
+ user: User & UserData,
43
+ data: { username: string },
44
+ ) => Promise<{ result: boolean; message?: string }>;
33
45
  /**
34
46
  * Theses are run before the user data checks, so the only checks before are
35
47
  * whether registration is allowed and whether the user is already registered.
36
48
  */
37
49
  export type TilmeldBeforeRegisterCallback = (
38
50
  user: User & UserData,
39
- data: { password: string; additionalData?: { [k: string]: any } }
51
+ data: { password: string; additionalData?: { [k: string]: any } },
40
52
  ) => Promise<void>;
41
53
  export type TilmeldAfterRegisterCallback = (
42
54
  user: User & UserData,
43
- result: { loggedin: boolean; message: string }
55
+ result: { loggedin: boolean; message: string },
44
56
  ) => Promise<void>;
45
57
  /**
46
58
  * These are run after the authentication checks, but before the login action.
@@ -51,20 +63,20 @@ export type TilmeldBeforeLoginCallback = (
51
63
  username: string;
52
64
  password: string;
53
65
  additionalData?: { [k: string]: any };
54
- }
66
+ },
55
67
  ) => Promise<void>;
56
68
  /**
57
69
  * This is run before the transaction is committed, and you can perform
58
70
  * additional functions on the transaction, which is available in `user.$nymph`.
59
71
  */
60
72
  export type TilmeldAfterLoginCallback = (
61
- user: User & UserData
73
+ user: User & UserData,
62
74
  ) => Promise<void>;
63
75
  export type TilmeldBeforeLogoutCallback = (
64
- user: User & UserData
76
+ user: User & UserData,
65
77
  ) => Promise<void>;
66
78
  export type TilmeldAfterLogoutCallback = (
67
- user: User & UserData
79
+ user: User & UserData,
68
80
  ) => Promise<void>;
69
81
 
70
82
  export type UserData = {
@@ -135,7 +147,8 @@ export type UserData = {
135
147
  */
136
148
  newEmailSecret?: string;
137
149
  /**
138
- * The new email address.
150
+ * If the user has changed their email address, this is the new one, awaiting
151
+ * verification.
139
152
  */
140
153
  newEmailAddress?: string;
141
154
  /**
@@ -167,6 +180,15 @@ export type UserData = {
167
180
  * database.
168
181
  */
169
182
  passwordTemp?: string;
183
+ /**
184
+ * If set, this timestamp is the cutoff point for JWT issue dates. Any token
185
+ * issued before this date will not authenticate the user.
186
+ */
187
+ revokeTokenDate?: number;
188
+ /**
189
+ * Two factor auth secret.
190
+ */
191
+ totpSecret?: string;
170
192
  };
171
193
 
172
194
  /**
@@ -182,9 +204,9 @@ export default class User extends AbleObject<UserData> {
182
204
  /**
183
205
  * The instance of Tilmeld to use for queries.
184
206
  */
185
- static tilmeld: Tilmeld;
186
207
  static ETYPE = 'tilmeld_user';
187
208
  static class = 'User';
209
+ private static checkUsernameCallbacks: TilmeldCheckUsernameCallback[] = [];
188
210
  private static beforeRegisterCallbacks: TilmeldBeforeRegisterCallback[] = [];
189
211
  private static afterRegisterCallbacks: TilmeldAfterRegisterCallback[] = [];
190
212
  private static beforeLoginCallbacks: TilmeldBeforeLoginCallback[] = [];
@@ -200,23 +222,33 @@ export default class User extends AbleObject<UserData> {
200
222
  '$register',
201
223
  ];
202
224
  private static DEFAULT_PRIVATE_DATA = [
225
+ // The things that are commented are there to show what is _not_ private.
226
+ 'abilities',
227
+ // 'username',
228
+ // 'nameFirst',
229
+ // 'nameMiddle',
230
+ // 'nameLast',
231
+ // 'name',
203
232
  'email',
204
- 'originalEmail',
233
+ // 'avatar',
205
234
  'phone',
206
235
  'group',
207
236
  'groups',
208
- 'abilities',
209
237
  'inheritAbilities',
210
- 'recoverSecret',
211
- 'recoverSecretDate',
212
- 'password',
213
- 'salt',
238
+ 'enabled',
214
239
  'secret',
215
- 'newEmailAddress',
240
+ 'emailChangeDate',
216
241
  'newEmailSecret',
217
- 'cancelEmailAddress',
242
+ 'newEmailAddress',
218
243
  'cancelEmailSecret',
219
- 'emailChangeDate',
244
+ 'cancelEmailAddress',
245
+ 'recoverSecret',
246
+ 'recoverSecretDate',
247
+ 'salt',
248
+ 'password',
249
+ 'passwordTemp',
250
+ 'revokeTokenDate',
251
+ 'totpSecret',
220
252
  ];
221
253
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
222
254
 
@@ -271,7 +303,7 @@ export default class User extends AbleObject<UserData> {
271
303
  {
272
304
  type: '&',
273
305
  ilike: ['username', username.replace(/([\\%_])/g, (s) => `\\${s}`)],
274
- }
306
+ },
275
307
  );
276
308
  if (entity != null) {
277
309
  return entity;
@@ -280,34 +312,33 @@ export default class User extends AbleObject<UserData> {
280
312
  return entity;
281
313
  }
282
314
 
283
- static factorySync(guid?: string): User & UserData {
284
- return super.factorySync(guid) as User & UserData;
315
+ static factorySync(): User & UserData {
316
+ return super.factorySync() as User & UserData;
285
317
  }
286
318
 
287
- constructor(guid?: string) {
288
- super(guid);
319
+ constructor() {
320
+ super();
289
321
 
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
- }
322
+ // Defaults.
323
+ this.$data.enabled = true;
324
+ this.$data.abilities = [];
325
+ this.$data.groups = [];
326
+ this.$data.inheritAbilities = true;
327
+ this.$updateDataProtection();
298
328
  }
299
329
 
300
330
  public static current(returnObjectIfNotExist: true): User & UserData;
301
331
  public static current(
302
- returnObjectIfNotExist?: false
332
+ returnObjectIfNotExist?: false,
303
333
  ): (User & UserData) | null;
304
334
  public static current(
305
- returnObjectIfNotExist?: boolean
335
+ returnObjectIfNotExist?: boolean,
306
336
  ): (User & UserData) | null {
307
- if (this.tilmeld.currentUser == null) {
337
+ const tilmeld = enforceTilmeld(this);
338
+ if (tilmeld.currentUser == null) {
308
339
  return returnObjectIfNotExist ? this.factorySync() : null;
309
340
  }
310
- return this.tilmeld.currentUser;
341
+ return tilmeld.currentUser;
311
342
  }
312
343
 
313
344
  /**
@@ -320,7 +351,8 @@ export default class User extends AbleObject<UserData> {
320
351
  recoveryType: 'username' | 'password';
321
352
  account: string;
322
353
  }): Promise<{ result: boolean; message: string }> {
323
- if (!this.tilmeld.config.pwRecovery) {
354
+ const tilmeld = enforceTilmeld(this);
355
+ if (!tilmeld.config.pwRecovery) {
324
356
  return {
325
357
  result: false,
326
358
  message: 'Account recovery is not allowed.',
@@ -330,18 +362,15 @@ export default class User extends AbleObject<UserData> {
330
362
  let user: User & UserData;
331
363
  const options: EmailOptions = {};
332
364
 
333
- if (
334
- !this.tilmeld.config.emailUsernames &&
335
- data.recoveryType === 'username'
336
- ) {
365
+ if (!tilmeld.config.emailUsernames && data.recoveryType === 'username') {
337
366
  // Create a username recovery email.
338
367
 
339
368
  const getUser = await this.nymph.getEntity(
340
- { class: this.tilmeld.User, skipAc: true },
369
+ { class: tilmeld.User, skipAc: true },
341
370
  {
342
371
  type: '&',
343
372
  ilike: ['email', data.account.replace(/([\\%_])/g, (s) => `\\${s}`)],
344
- }
373
+ },
345
374
  );
346
375
 
347
376
  if (getUser == null) {
@@ -364,7 +393,7 @@ export default class User extends AbleObject<UserData> {
364
393
  } else if (data.recoveryType === 'password') {
365
394
  // Create a password recovery email.
366
395
 
367
- const getUser = await this.tilmeld.User.factoryUsername(data.account);
396
+ const getUser = await tilmeld.User.factoryUsername(data.account);
368
397
 
369
398
  if (getUser.guid == null) {
370
399
  return {
@@ -391,14 +420,14 @@ export default class User extends AbleObject<UserData> {
391
420
  };
392
421
  options.locals = {
393
422
  recoverCode: getUser.recoverSecret,
394
- timeLimit: this.tilmeld.config.pwRecoveryTimeLimit,
423
+ timeLimit: tilmeld.config.pwRecoveryTimeLimit,
395
424
  };
396
425
  } else {
397
426
  return { result: false, message: 'Invalid recovery type.' };
398
427
  }
399
428
 
400
429
  // Send the email.
401
- if (await this.tilmeld.config.sendEmail(this.tilmeld, options, user)) {
430
+ if (await tilmeld.config.sendEmail(tilmeld, options, user)) {
402
431
  return {
403
432
  result: true,
404
433
  message:
@@ -421,22 +450,23 @@ export default class User extends AbleObject<UserData> {
421
450
  secret: string;
422
451
  password: string;
423
452
  }): Promise<{ result: boolean; message: string }> {
424
- if (!this.tilmeld.config.pwRecovery) {
453
+ const tilmeld = enforceTilmeld(this);
454
+ if (!tilmeld.config.pwRecovery) {
425
455
  return {
426
456
  result: false,
427
457
  message: 'Account recovery is not allowed.',
428
458
  };
429
459
  }
430
460
 
431
- const user = await this.tilmeld.User.factoryUsername(data.username);
461
+ const user = await tilmeld.User.factoryUsername(data.username);
432
462
 
433
463
  if (
434
464
  user.guid == null ||
435
465
  user.recoverSecret == null ||
436
466
  data.secret !== user.recoverSecret ||
437
467
  strtotime(
438
- '+' + this.tilmeld.config.pwRecoveryTimeLimit,
439
- Math.floor((user.recoverSecretDate ?? 0) / 1000)
468
+ '+' + tilmeld.config.pwRecoveryTimeLimit,
469
+ Math.floor((user.recoverSecretDate ?? 0) / 1000),
440
470
  ) *
441
471
  1000 <
442
472
  Date.now()
@@ -466,27 +496,30 @@ export default class User extends AbleObject<UserData> {
466
496
  }
467
497
 
468
498
  public static getClientConfig() {
499
+ const tilmeld = enforceTilmeld(this);
469
500
  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,
501
+ regFields: tilmeld.config.regFields,
502
+ userFields: tilmeld.config.userFields,
503
+ emailUsernames: tilmeld.config.emailUsernames,
504
+ allowRegistration: tilmeld.config.allowRegistration,
505
+ allowUsernameChange: tilmeld.config.allowUsernameChange,
506
+ pwRecovery: tilmeld.config.pwRecovery,
507
+ verifyEmail: tilmeld.config.verifyEmail,
508
+ unverifiedAccess: tilmeld.config.unverifiedAccess,
478
509
  };
479
510
  }
480
511
 
481
512
  public static async loginUser(data: {
482
513
  username: string;
483
514
  password: string;
515
+ code?: string;
484
516
  additionalData?: { [k: string]: any };
485
517
  }) {
518
+ const tilmeld = enforceTilmeld(this);
486
519
  if (!('username' in data) || !data.username.length) {
487
520
  return { result: false, message: 'Incorrect login/password.' };
488
521
  }
489
- const user = await this.tilmeld.User.factoryUsername(data.username);
522
+ const user = await tilmeld.User.factoryUsername(data.username);
490
523
  const result: { result: boolean; message: string; user?: User & UserData } =
491
524
  await user.$login(data);
492
525
  if (result.result) {
@@ -499,22 +532,41 @@ export default class User extends AbleObject<UserData> {
499
532
  public async $login(data: {
500
533
  username: string;
501
534
  password: string;
535
+ code?: string;
502
536
  additionalData?: { [k: string]: any };
503
537
  }) {
504
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
538
+ const tilmeld = enforceTilmeld(this);
505
539
  if (this.guid == null) {
506
540
  return { result: false, message: 'Incorrect login/password.' };
507
541
  }
508
542
  if (!this.$data.enabled) {
509
543
  return { result: false, message: 'This user is disabled.' };
510
544
  }
511
- if (this.$gatekeeper()) {
545
+ if (await this.$gatekeeper()) {
512
546
  return { result: true, message: 'You are already logged in.' };
513
547
  }
514
548
  if (!this.$checkPassword(data.password)) {
515
549
  return { result: false, message: 'Incorrect login/password.' };
516
550
  }
517
551
 
552
+ if (this.$data.totpSecret != null) {
553
+ if (
554
+ data.code == null ||
555
+ typeof data.code !== 'string' ||
556
+ data.code.length !== 6
557
+ ) {
558
+ return {
559
+ result: false,
560
+ needTOTP: true,
561
+ message: 'You need to provide a 2FA code.',
562
+ };
563
+ }
564
+
565
+ if (!this.$checkTOTPCode(data.code)) {
566
+ return { result: false, message: 'Incorrect 2FA code.' };
567
+ }
568
+ }
569
+
518
570
  try {
519
571
  for (let callback of (this.constructor as typeof User)
520
572
  .beforeLoginCallbacks) {
@@ -530,7 +582,79 @@ export default class User extends AbleObject<UserData> {
530
582
  }
531
583
 
532
584
  // Authentication was successful, attempt to login.
533
- if (!tilmeld.login(this, true)) {
585
+ if (!(await tilmeld.login(this, true))) {
586
+ return { result: false, message: 'Incorrect login/password.' };
587
+ }
588
+
589
+ for (let callback of (this.constructor as typeof User)
590
+ .afterLoginCallbacks) {
591
+ if (callback) {
592
+ await callback(this);
593
+ }
594
+ }
595
+
596
+ // Login was successful.
597
+ return { result: true, message: 'You are logged in.' };
598
+ }
599
+
600
+ public async $switchUser(data?: { additionalData?: { [k: string]: any } }) {
601
+ const tilmeld = enforceTilmeld(this);
602
+ if (this.guid == null) {
603
+ return { result: false, message: 'Incorrect login/password.' };
604
+ }
605
+
606
+ let user = tilmeld.User.current();
607
+
608
+ if (!user || user.guid == null) {
609
+ return { result: false, message: "You're not logged in." };
610
+ }
611
+
612
+ const isCurrentUser = user != null && user.$is(this);
613
+
614
+ if (isCurrentUser) {
615
+ return { result: false, message: "You can't switch to yourself." };
616
+ }
617
+
618
+ const toAbilities = this.$data.abilities || [];
619
+ const fromAbilities = user.abilities || [];
620
+
621
+ if (
622
+ fromAbilities.indexOf('tilmeld/switch') === -1 &&
623
+ fromAbilities.indexOf('system/admin') === -1
624
+ ) {
625
+ return {
626
+ result: false,
627
+ message: "You don't have permission to switch users.",
628
+ };
629
+ }
630
+
631
+ if (
632
+ toAbilities.indexOf('system/admin') !== -1 ||
633
+ toAbilities.indexOf('tilmeld/admin') !== -1
634
+ ) {
635
+ return { result: false, message: "You can't switch to an admin." };
636
+ }
637
+
638
+ try {
639
+ for (let callback of (this.constructor as typeof User)
640
+ .beforeLoginCallbacks) {
641
+ if (callback) {
642
+ await callback(this, {
643
+ username: this.$data.username || '',
644
+ password: '',
645
+ ...(data || {}),
646
+ });
647
+ }
648
+ }
649
+ } catch (e: any) {
650
+ return {
651
+ result: false,
652
+ message: e.message,
653
+ };
654
+ }
655
+
656
+ // Authentication was successful, attempt to login.
657
+ if (!(await tilmeld.loginSwitch(this, true))) {
534
658
  return { result: false, message: 'Incorrect login/password.' };
535
659
  }
536
660
 
@@ -550,7 +674,7 @@ export default class User extends AbleObject<UserData> {
550
674
  * @returns An object with a boolean 'result' entry and a 'message' entry.
551
675
  */
552
676
  public async $logout() {
553
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
677
+ const tilmeld = enforceTilmeld(this);
554
678
 
555
679
  try {
556
680
  for (let callback of (this.constructor as typeof User)
@@ -566,7 +690,7 @@ export default class User extends AbleObject<UserData> {
566
690
  };
567
691
  }
568
692
 
569
- tilmeld.logout();
693
+ await tilmeld.logout();
570
694
 
571
695
  for (let callback of (this.constructor as typeof User)
572
696
  .afterLogoutCallbacks) {
@@ -592,36 +716,36 @@ export default class User extends AbleObject<UserData> {
592
716
  );
593
717
  }
594
718
 
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
- }
719
+ public $getGid() {
720
+ if (this.$data.group) {
721
+ return this.$data.group.guid;
609
722
  }
610
- return this.$descendantGroups;
723
+ return null;
724
+ }
725
+
726
+ public $getGids() {
727
+ if (this.$data.groups) {
728
+ return this.$data.groups
729
+ .map((group) => group.guid)
730
+ .filter((guid) => guid != null) as string[];
731
+ }
732
+ return null;
611
733
  }
612
734
 
613
735
  /**
614
736
  * Get the user's group descendants.
615
737
  */
616
- public $getDescendantGroupsSync(): (Group & GroupData)[] {
738
+ public async $getDescendantGroups(): Promise<(Group & GroupData)[]> {
617
739
  if (this.$descendantGroups == null) {
618
740
  this.$descendantGroups = [];
619
741
  if (this.$data.group != null) {
620
- this.$descendantGroups = this.$data.group.$getDescendantsSync();
742
+ await this.$data.group.$wake();
743
+ this.$descendantGroups = await this.$data.group.$getDescendants();
621
744
  }
745
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
622
746
  for (let curGroup of this.$data.groups ?? []) {
623
747
  this.$descendantGroups = this.$descendantGroups?.concat(
624
- curGroup.$getDescendantsSync()
748
+ await curGroup.$getDescendants(),
625
749
  );
626
750
  }
627
751
  }
@@ -629,18 +753,42 @@ export default class User extends AbleObject<UserData> {
629
753
  }
630
754
 
631
755
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
632
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
633
- this.$referenceWake();
756
+ const tilmeld = enforceTilmeld(this);
757
+ this.$check();
634
758
 
635
759
  if (
636
760
  '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')
761
+ input.data.abilities?.includes('system/admin') !==
762
+ this.$data.abilities?.includes('system/admin') &&
763
+ (!tilmeld.currentUser ||
764
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
765
+ ) {
766
+ throw new BadDataError(
767
+ "You don't have the authority to grant or revoke system/admin.",
768
+ );
769
+ }
770
+
771
+ if (
772
+ 'abilities' in input.data &&
773
+ input.data.abilities?.includes('tilmeld/admin') !==
774
+ this.$data.abilities?.includes('tilmeld/admin') &&
775
+ (!tilmeld.currentUser ||
776
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
641
777
  ) {
642
778
  throw new BadDataError(
643
- "You don't have the authority to make this user a system admin."
779
+ "You don't have the authority to grant or revoke tilmeld/admin.",
780
+ );
781
+ }
782
+
783
+ if (
784
+ 'abilities' in input.data &&
785
+ input.data.abilities?.includes('tilmeld/switch') !==
786
+ this.$data.abilities?.includes('tilmeld/switch') &&
787
+ (!tilmeld.currentUser ||
788
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
789
+ ) {
790
+ throw new BadDataError(
791
+ "You don't have the authority to grant or revoke tilmeld/switch.",
644
792
  );
645
793
  }
646
794
 
@@ -649,18 +797,42 @@ export default class User extends AbleObject<UserData> {
649
797
  }
650
798
 
651
799
  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
652
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
653
- this.$referenceWake();
800
+ const tilmeld = enforceTilmeld(this);
801
+ this.$check();
654
802
 
655
803
  if (
656
804
  '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')
805
+ patch.set.abilities?.includes('system/admin') !==
806
+ this.$data.abilities?.includes('system/admin') &&
807
+ (!tilmeld.currentUser ||
808
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
809
+ ) {
810
+ throw new BadDataError(
811
+ "You don't have the authority to grant or revoke system/admin.",
812
+ );
813
+ }
814
+
815
+ if (
816
+ 'abilities' in patch.set &&
817
+ patch.set.abilities?.includes('tilmeld/admin') !==
818
+ this.$data.abilities?.includes('tilmeld/admin') &&
819
+ (!tilmeld.currentUser ||
820
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
821
+ ) {
822
+ throw new BadDataError(
823
+ "You don't have the authority to grant or revoke tilmeld/admin.",
824
+ );
825
+ }
826
+
827
+ if (
828
+ 'abilities' in patch.set &&
829
+ patch.set.abilities?.includes('tilmeld/switch') !==
830
+ this.$data.abilities?.includes('tilmeld/switch') &&
831
+ (!tilmeld.currentUser ||
832
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
661
833
  ) {
662
834
  throw new BadDataError(
663
- "You don't have the authority to make this user a system admin."
835
+ "You don't have the authority to grant or revoke tilmeld/switch.",
664
836
  );
665
837
  }
666
838
 
@@ -679,7 +851,7 @@ export default class User extends AbleObject<UserData> {
679
851
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
680
852
  */
681
853
  public $updateDataProtection(givenUser?: User & UserData) {
682
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
854
+ const tilmeld = enforceTilmeld(this);
683
855
  let user = givenUser ?? tilmeld.User.current();
684
856
 
685
857
  this.$clientEnabledMethods = [...User.DEFAULT_CLIENT_ENABLED_METHODS];
@@ -703,8 +875,13 @@ export default class User extends AbleObject<UserData> {
703
875
  // Users can check to see what abilities they have.
704
876
  this.$clientEnabledMethods.push('$gatekeeper');
705
877
  this.$clientEnabledMethods.push('$changePassword');
878
+ this.$clientEnabledMethods.push('$revokeCurrentTokens');
706
879
  this.$clientEnabledMethods.push('$logout');
707
880
  this.$clientEnabledMethods.push('$sendEmailVerification');
881
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
882
+ this.$clientEnabledMethods.push('$getNewTOTPSecret');
883
+ this.$clientEnabledMethods.push('$saveTOTPSecret');
884
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
708
885
  }
709
886
 
710
887
  if (
@@ -713,7 +890,9 @@ export default class User extends AbleObject<UserData> {
713
890
  abilities.indexOf('system/admin') !== -1)
714
891
  ) {
715
892
  // Users who can edit other users can see most of their data.
716
- this.$privateData = ['password', 'salt'];
893
+ this.$privateData = ['password', 'salt', 'totpSecret'];
894
+ this.$clientEnabledMethods.push('$hasTOTPSecret');
895
+ this.$clientEnabledMethods.push('$removeTOTPSecret');
717
896
  this.$allowlistData = undefined;
718
897
  } else if (isCurrentUser || isNewUser) {
719
898
  // Users can see their own data, and edit some of it.
@@ -734,17 +913,56 @@ export default class User extends AbleObject<UserData> {
734
913
  this.$allowlistData.push('phone');
735
914
  }
736
915
  this.$privateData = [
916
+ // The things that are commented are there to show what is _not_ private.
917
+ // 'abilities',
918
+ // 'username',
919
+ // 'nameFirst',
920
+ // 'nameMiddle',
921
+ // 'nameLast',
922
+ // 'name',
923
+ // 'email',
924
+ // 'avatar',
925
+ // 'phone',
926
+ // 'group',
927
+ // 'groups',
928
+ // 'inheritAbilities',
929
+ 'enabled',
737
930
  'secret',
931
+ 'emailChangeDate',
738
932
  'newEmailSecret',
739
- 'cancelEmailAddress',
933
+ // 'newEmailAddress',
740
934
  'cancelEmailSecret',
741
- 'emailChangeDate',
935
+ 'cancelEmailAddress',
742
936
  'recoverSecret',
743
937
  'recoverSecretDate',
744
- 'password',
745
938
  'salt',
939
+ 'password',
940
+ 'passwordTemp',
941
+ // 'revokeTokenDate',
942
+ 'totpSecret',
746
943
  ];
747
944
  }
945
+
946
+ if (
947
+ user != null &&
948
+ !isCurrentUser &&
949
+ (abilities.indexOf('tilmeld/switch') !== -1 ||
950
+ abilities.indexOf('system/admin') !== -1)
951
+ ) {
952
+ // Users with this ability can switch to other users.
953
+ this.$clientEnabledMethods.push('$switchUser');
954
+ }
955
+ }
956
+
957
+ public $updateGroupDataProtection() {
958
+ if (this.$data.group != null && !this.$data.group.$asleep()) {
959
+ this.$data.group.$updateDataProtection();
960
+ }
961
+ for (let group of this.$data.groups ?? []) {
962
+ if (!group.$asleep()) {
963
+ group.$updateDataProtection();
964
+ }
965
+ }
748
966
  }
749
967
 
750
968
  /**
@@ -761,35 +979,43 @@ export default class User extends AbleObject<UserData> {
761
979
  * @param ability The ability.
762
980
  * @returns True or false.
763
981
  */
764
- public $gatekeeper(ability?: string): boolean {
765
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
982
+ public async $gatekeeper(ability?: string): Promise<boolean> {
983
+ await this.$wake();
984
+ const tilmeld = enforceTilmeld(this);
766
985
  if (ability == null) {
767
986
  return tilmeld.User.current()?.$is(this) ?? false;
768
987
  }
769
988
  // Check the cache to see if we've already checked this user.
770
989
  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
- );
990
+ this.$gatekeeperCache = await this.$getGatekeeperCache();
789
991
  }
790
992
  return (
791
993
  (ability in this.$gatekeeperCache && !!this.$gatekeeperCache[ability]) ||
792
- !!this.$gatekeeperCache['system/admin']
994
+ !!this.$data.abilities?.includes('system/admin')
995
+ );
996
+ }
997
+
998
+ /**
999
+ * Build a gatekeeper cache object.
1000
+ */
1001
+ public async $getGatekeeperCache() {
1002
+ await this.$wake();
1003
+ let abilities = this.$data.abilities ?? [];
1004
+ if (this.$data.inheritAbilities) {
1005
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1006
+ for (let curGroup of this.$data.groups ?? []) {
1007
+ if (curGroup.enabled) {
1008
+ abilities = abilities.concat(curGroup.abilities ?? []);
1009
+ }
1010
+ }
1011
+ await this.$data.group?.$wake();
1012
+ const group = this.$data.group;
1013
+ if (group != null && group.cdate != null && group.enabled) {
1014
+ abilities = abilities.concat(group.abilities ?? []);
1015
+ }
1016
+ }
1017
+ return Object.fromEntries(
1018
+ abilities.map((ability) => [ability, true as true]),
793
1019
  );
794
1020
  }
795
1021
 
@@ -806,7 +1032,7 @@ export default class User extends AbleObject<UserData> {
806
1032
  * @returns True on success, false on failure.
807
1033
  */
808
1034
  public async $sendEmailVerification() {
809
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1035
+ const tilmeld = enforceTilmeld(this);
810
1036
  if (this.guid == null) {
811
1037
  return false;
812
1038
  }
@@ -818,7 +1044,7 @@ export default class User extends AbleObject<UserData> {
818
1044
 
819
1045
  if (this.$data.secret != null) {
820
1046
  const link = `${verifyUrl}?action=verify&id=${encodeURIComponent(
821
- this.guid
1047
+ this.guid,
822
1048
  )}&secret=${encodeURIComponent(this.$data.secret)}`;
823
1049
  success =
824
1050
  success &&
@@ -836,13 +1062,13 @@ export default class User extends AbleObject<UserData> {
836
1062
  verifyLink: link,
837
1063
  },
838
1064
  },
839
- this
1065
+ this,
840
1066
  ));
841
1067
  }
842
1068
 
843
1069
  if (this.$data.newEmailSecret != null) {
844
1070
  const link = `${verifyUrl}?action=verifychange&id=${encodeURIComponent(
845
- this.guid
1071
+ this.guid,
846
1072
  )}&secret=${encodeURIComponent(this.$data.newEmailSecret)}`;
847
1073
  success =
848
1074
  success &&
@@ -862,13 +1088,13 @@ export default class User extends AbleObject<UserData> {
862
1088
  newEmail: this.$data.newEmailAddress,
863
1089
  },
864
1090
  },
865
- this
1091
+ this,
866
1092
  ));
867
1093
  }
868
1094
 
869
1095
  if (this.$data.cancelEmailSecret != null) {
870
1096
  const link = `${verifyUrl}?action=cancelchange&id=${encodeURIComponent(
871
- this.guid
1097
+ this.guid,
872
1098
  )}&secret=${encodeURIComponent(this.$data.cancelEmailSecret)}`;
873
1099
  success =
874
1100
  success &&
@@ -888,7 +1114,7 @@ export default class User extends AbleObject<UserData> {
888
1114
  newEmail: this.$data.newEmailAddress ?? this.$data.email,
889
1115
  },
890
1116
  },
891
- this
1117
+ this,
892
1118
  ));
893
1119
  }
894
1120
 
@@ -901,13 +1127,16 @@ export default class User extends AbleObject<UserData> {
901
1127
  * @param group The group.
902
1128
  * @returns True if the user is already in the group. The resulting array of groups if the user was not.
903
1129
  */
904
- public $addGroup(group: Group & GroupData) {
905
- if (this.$data.groups == null) {
906
- this.$data.groups = [];
1130
+ public async $addGroup(group: Group & GroupData) {
1131
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1132
+ let groups = this.$data.groups;
1133
+ if (groups == null) {
1134
+ groups = [];
907
1135
  }
908
- if (!group.$inArray(this.$data.groups)) {
909
- this.$data.groups.push(group);
910
- return this.$data.groups;
1136
+ if (!group.$inArray(groups)) {
1137
+ groups.push(group);
1138
+ this.$data.groups = groups;
1139
+ return groups;
911
1140
  }
912
1141
  return true;
913
1142
  }
@@ -919,7 +1148,7 @@ export default class User extends AbleObject<UserData> {
919
1148
  * @returns True if the passwords match, otherwise false.
920
1149
  */
921
1150
  public $checkPassword(password: string) {
922
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1151
+ const tilmeld = enforceTilmeld(this);
923
1152
  switch (tilmeld.config.pwMethod) {
924
1153
  case 'plain':
925
1154
  return this.$data.password === password;
@@ -940,10 +1169,12 @@ export default class User extends AbleObject<UserData> {
940
1169
  * @param group The group.
941
1170
  * @returns True if the user wasn't in the group. The resulting array of groups if the user was.
942
1171
  */
943
- public $delGroup(group: Group & GroupData) {
944
- if (this.$data.groups != null && group.$inArray(this.$data.groups)) {
1172
+ public async $delGroup(group: Group & GroupData) {
1173
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
1174
+ let groups = this.$data.groups || [];
1175
+ if (this.$data.groups != null && group.$inArray(groups)) {
945
1176
  const newGroups: (Group & GroupData)[] = [];
946
- for (let curGroup of this.$data.groups) {
1177
+ for (let curGroup of groups) {
947
1178
  if (!group.$is(curGroup)) {
948
1179
  newGroups.push(curGroup);
949
1180
  }
@@ -960,7 +1191,7 @@ export default class User extends AbleObject<UserData> {
960
1191
  * @param group The group, or the group's GUID.
961
1192
  * @returns True or false.
962
1193
  */
963
- public $inGroup(group: (Group & GroupData) | string) {
1194
+ public async $inGroup(group: (Group & GroupData) | string) {
964
1195
  if (typeof group === 'string') {
965
1196
  if (this.$data.group?.guid === group) {
966
1197
  return true;
@@ -983,16 +1214,19 @@ export default class User extends AbleObject<UserData> {
983
1214
  * @param group The group, or the group's GUID.
984
1215
  * @returns True or false.
985
1216
  */
986
- public $isDescendant(group: (Group & GroupData) | string) {
1217
+ public async $isDescendant(group: (Group & GroupData) | string) {
987
1218
  // Check to see if the user is in a descendant group of the given group.
1219
+ await this.$data.group?.$wake();
988
1220
  if (
1221
+ this.$data.group != null &&
989
1222
  this.$data.group?.cdate != null &&
990
- this.$data.group.$isDescendant(group)
1223
+ (await this.$data.group?.$isDescendant(group))
991
1224
  ) {
992
1225
  return true;
993
1226
  }
1227
+ await Promise.all(this.$data.groups?.map((e) => e.$wake()) || []);
994
1228
  for (let curGroup of this.$data.groups ?? []) {
995
- if (curGroup.$isDescendant(group)) {
1229
+ if (await curGroup.$isDescendant(group)) {
996
1230
  return true;
997
1231
  }
998
1232
  }
@@ -1008,6 +1242,7 @@ export default class User extends AbleObject<UserData> {
1008
1242
  public async $changePassword(data: {
1009
1243
  newPassword: string;
1010
1244
  currentPassword: string;
1245
+ revokeCurrentTokens?: boolean;
1011
1246
  }): Promise<{ result: boolean; message: string }> {
1012
1247
  if (!('newPassword' in data) || !data.newPassword.length) {
1013
1248
  return { result: false, message: 'Please specify a password.' };
@@ -1016,7 +1251,14 @@ export default class User extends AbleObject<UserData> {
1016
1251
  return { result: false, message: 'Incorrect password.' };
1017
1252
  }
1018
1253
  this.$data.passwordTemp = data.newPassword;
1254
+ if (data.revokeCurrentTokens) {
1255
+ this.$data.revokeTokenDate = Date.now();
1256
+ }
1019
1257
  if (await this.$save()) {
1258
+ if (data.revokeCurrentTokens) {
1259
+ const tilmeld = enforceTilmeld(this);
1260
+ await tilmeld.login(this, true);
1261
+ }
1020
1262
  return { result: true, message: 'Your password has been changed.' };
1021
1263
  } else {
1022
1264
  return { result: false, message: "Couldn't save new password." };
@@ -1030,7 +1272,7 @@ export default class User extends AbleObject<UserData> {
1030
1272
  * @returns The resulting password or hash which is stored in the entity.
1031
1273
  */
1032
1274
  public $password(password: string) {
1033
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1275
+ const tilmeld = enforceTilmeld(this);
1034
1276
  switch (tilmeld.config.pwMethod) {
1035
1277
  case 'plain':
1036
1278
  delete this.$data.salt;
@@ -1044,28 +1286,244 @@ export default class User extends AbleObject<UserData> {
1044
1286
  default:
1045
1287
  this.$data.salt = nanoid();
1046
1288
  this.$data.password = sha256(password + this.$data.salt).toString(
1047
- Base64
1289
+ Base64,
1048
1290
  );
1049
1291
  break;
1050
1292
  }
1051
1293
  return this.$data.password;
1052
1294
  }
1053
1295
 
1296
+ /**
1297
+ * A frontend accessible method to revoke all currently issued tokens.
1298
+ *
1299
+ * @param data The input data from the client.
1300
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1301
+ */
1302
+ public async $revokeCurrentTokens(data: {
1303
+ password: string;
1304
+ }): Promise<{ result: boolean; message: string }> {
1305
+ if (!this.$checkPassword(data.password ?? '')) {
1306
+ return { result: false, message: 'Incorrect password.' };
1307
+ }
1308
+
1309
+ this.$data.revokeTokenDate = Date.now();
1310
+
1311
+ if (await this.$save()) {
1312
+ const tilmeld = enforceTilmeld(this);
1313
+ await tilmeld.login(this, true);
1314
+ return {
1315
+ result: true,
1316
+ message: 'You have logged out of all other sessions.',
1317
+ };
1318
+ } else {
1319
+ return { result: false, message: "Couldn't save revocation date." };
1320
+ }
1321
+ }
1322
+
1323
+ public async $hasTOTPSecret() {
1324
+ return this.$data.totpSecret != null;
1325
+ }
1326
+
1327
+ /**
1328
+ * Check the given code against the user's TOTP secret.
1329
+ *
1330
+ * @param code The code in question.
1331
+ * @returns True if the code is valid, otherwise false.
1332
+ */
1333
+ public $checkTOTPCode(code: string) {
1334
+ if (this.$data.totpSecret == null) {
1335
+ return false;
1336
+ }
1337
+
1338
+ const tilmeld = enforceTilmeld(this);
1339
+
1340
+ const totp = new TOTP({
1341
+ issuer: tilmeld.config.appName,
1342
+ label: this.$data.username,
1343
+ algorithm: 'SHA1',
1344
+ digits: 6,
1345
+ period: 30,
1346
+ secret: this.$data.totpSecret,
1347
+ });
1348
+
1349
+ return totp.validate({ token: code, window: 2 }) != null;
1350
+ }
1351
+
1352
+ /**
1353
+ * A frontend accessible method to generate a new TOTP secret.
1354
+ *
1355
+ * @returns An object with 'uri', 'qrcode', and 'secret'.
1356
+ */
1357
+ public async $getNewTOTPSecret() {
1358
+ const tilmeld = enforceTilmeld(this);
1359
+
1360
+ if (this.$data.totpSecret != null) {
1361
+ throw new BadDataError('You already have a 2FA secret.');
1362
+ }
1363
+
1364
+ const secret = new Secret();
1365
+
1366
+ const totp = new TOTP({
1367
+ issuer: tilmeld.config.appName,
1368
+ label: this.$data.username,
1369
+ algorithm: 'SHA1',
1370
+ digits: 6,
1371
+ period: 30,
1372
+ secret,
1373
+ });
1374
+
1375
+ const uri = totp.toString();
1376
+
1377
+ const qrcode = await toDataURL(uri);
1378
+
1379
+ return {
1380
+ uri,
1381
+ qrcode,
1382
+ secret: secret.base32,
1383
+ };
1384
+ }
1385
+
1386
+ /**
1387
+ * A frontend accessible method to save a TOTP secret into the user's account.
1388
+ *
1389
+ * @param data The input data from the client.
1390
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1391
+ */
1392
+ public async $saveTOTPSecret(data: {
1393
+ password: string;
1394
+ secret: string;
1395
+ code: string;
1396
+ }): Promise<{ result: boolean; message: string }> {
1397
+ if (!this.$checkPassword(data.password ?? '')) {
1398
+ return { result: false, message: 'Incorrect password.' };
1399
+ }
1400
+
1401
+ if (this.$data.totpSecret != null) {
1402
+ return { result: false, message: 'You already have a 2FA secret.' };
1403
+ }
1404
+
1405
+ if (
1406
+ data.secret == null ||
1407
+ typeof data.secret !== 'string' ||
1408
+ data.secret === ''
1409
+ ) {
1410
+ return { result: false, message: '2FA secret is invalid.' };
1411
+ }
1412
+
1413
+ if (
1414
+ data.code == null ||
1415
+ typeof data.code !== 'string' ||
1416
+ data.code.length !== 6
1417
+ ) {
1418
+ return { result: false, message: '2FA code is invalid.' };
1419
+ }
1420
+
1421
+ const tilmeld = enforceTilmeld(this);
1422
+
1423
+ const totp = new TOTP({
1424
+ issuer: tilmeld.config.appName,
1425
+ label: this.$data.username,
1426
+ algorithm: 'SHA1',
1427
+ digits: 6,
1428
+ period: 30,
1429
+ secret: data.secret,
1430
+ });
1431
+
1432
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1433
+ return { result: false, message: '2FA code is incorrect.' };
1434
+ }
1435
+
1436
+ this.$data.totpSecret = data.secret;
1437
+
1438
+ if (await this.$save()) {
1439
+ return { result: true, message: 'Your two factor secret is now set.' };
1440
+ } else {
1441
+ return { result: false, message: "Couldn't save two factor secret." };
1442
+ }
1443
+ }
1444
+
1445
+ /**
1446
+ * A frontend accessible method to remove the TOTP secret from the user's
1447
+ * account.
1448
+ *
1449
+ * @param data The input data from the client.
1450
+ * @returns An object with a boolean 'result' entry and a 'message' entry.
1451
+ */
1452
+ public async $removeTOTPSecret(data?: {
1453
+ password: string;
1454
+ code: string;
1455
+ }): Promise<{ result: boolean; message: string }> {
1456
+ const tilmeld = enforceTilmeld(this);
1457
+
1458
+ if (data) {
1459
+ if (!this.$checkPassword(data.password ?? '')) {
1460
+ return { result: false, message: 'Incorrect password.' };
1461
+ }
1462
+
1463
+ if (
1464
+ data.code == null ||
1465
+ typeof data.code !== 'string' ||
1466
+ data.code.length !== 6
1467
+ ) {
1468
+ return { result: false, message: '2FA code is invalid.' };
1469
+ }
1470
+
1471
+ if (this.$data.totpSecret == null) {
1472
+ return { result: false, message: "You don't have a 2FA secret." };
1473
+ }
1474
+
1475
+ const totp = new TOTP({
1476
+ issuer: tilmeld.config.appName,
1477
+ label: this.$data.username,
1478
+ algorithm: 'SHA1',
1479
+ digits: 6,
1480
+ period: 30,
1481
+ secret: this.$data.totpSecret,
1482
+ });
1483
+
1484
+ if (totp.validate({ token: data.code, window: 2 }) == null) {
1485
+ return { result: false, message: '2FA code is incorrect.' };
1486
+ }
1487
+ } else if (!tilmeld.gatekeeper('tilmeld/admin')) {
1488
+ return {
1489
+ result: false,
1490
+ message: 'You must provide your password and 2FA code.',
1491
+ };
1492
+ }
1493
+
1494
+ delete this.$data.totpSecret;
1495
+
1496
+ if (await this.$save()) {
1497
+ return {
1498
+ result: true,
1499
+ message: 'Two factor secret has been removed.',
1500
+ };
1501
+ } else {
1502
+ return { result: false, message: "Couldn't remove two factor secret." };
1503
+ }
1504
+ }
1505
+
1054
1506
  /**
1055
1507
  * Check that a username is valid.
1056
1508
  *
1057
1509
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1058
1510
  */
1059
1511
  public async $checkUsername(): Promise<{ result: boolean; message: string }> {
1060
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1512
+ const tilmeld = enforceTilmeld(this);
1061
1513
  if (!tilmeld.config.emailUsernames) {
1062
1514
  if (this.$data.username == null || !this.$data.username.length) {
1063
1515
  return { result: false, message: 'Please specify a username.' };
1064
1516
  }
1065
- if (
1066
- tilmeld.config.maxUsernameLength > 0 &&
1067
- this.$data.username.length > tilmeld.config.maxUsernameLength
1068
- ) {
1517
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1518
+ return {
1519
+ result: false,
1520
+ message:
1521
+ 'Usernames must be at least ' +
1522
+ tilmeld.config.minUsernameLength +
1523
+ ' characters.',
1524
+ };
1525
+ }
1526
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1069
1527
  return {
1070
1528
  result: false,
1071
1529
  message:
@@ -1078,7 +1536,7 @@ export default class User extends AbleObject<UserData> {
1078
1536
  if (
1079
1537
  difference(
1080
1538
  this.$data.username.split(''),
1081
- tilmeld.config.validChars.split('')
1539
+ tilmeld.config.validChars.split(''),
1082
1540
  ).length
1083
1541
  ) {
1084
1542
  return {
@@ -1105,12 +1563,24 @@ export default class User extends AbleObject<UserData> {
1105
1563
  }
1106
1564
  const test = await this.$nymph.getEntity(
1107
1565
  { class: tilmeld.User, skipAc: true },
1108
- selector
1566
+ selector,
1109
1567
  );
1110
1568
  if (test != null) {
1111
1569
  return { result: false, message: 'That username is taken.' };
1112
1570
  }
1113
1571
 
1572
+ for (let callback of (this.constructor as typeof User)
1573
+ .checkUsernameCallbacks) {
1574
+ if (callback) {
1575
+ const result = await callback(this, {
1576
+ username: this.$data.username,
1577
+ });
1578
+ if (!result.result) {
1579
+ return { message: 'Username is not available.', ...result };
1580
+ }
1581
+ }
1582
+ }
1583
+
1114
1584
  return {
1115
1585
  result: true,
1116
1586
  message:
@@ -1121,10 +1591,16 @@ export default class User extends AbleObject<UserData> {
1121
1591
  if (this.$data.username == null || !this.$data.username.length) {
1122
1592
  return { result: false, message: 'Please specify an email.' };
1123
1593
  }
1124
- if (
1125
- tilmeld.config.maxUsernameLength > 0 &&
1126
- this.$data.username.length > tilmeld.config.maxUsernameLength
1127
- ) {
1594
+ if (this.$data.username.length < tilmeld.config.minUsernameLength) {
1595
+ return {
1596
+ result: false,
1597
+ message:
1598
+ 'Emails must be at least ' +
1599
+ tilmeld.config.minUsernameLength +
1600
+ ' characters.',
1601
+ };
1602
+ }
1603
+ if (this.$data.username.length > tilmeld.config.maxUsernameLength) {
1128
1604
  return {
1129
1605
  result: false,
1130
1606
  message:
@@ -1144,7 +1620,7 @@ export default class User extends AbleObject<UserData> {
1144
1620
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1145
1621
  */
1146
1622
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
1147
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1623
+ const tilmeld = enforceTilmeld(this);
1148
1624
  if (this.$data.email == null || !this.$data.email.length) {
1149
1625
  if (tilmeld.config.verifyEmail) {
1150
1626
  return { result: false, message: 'Please specify an email.' };
@@ -1167,7 +1643,7 @@ export default class User extends AbleObject<UserData> {
1167
1643
  }
1168
1644
  const test = await this.$nymph.getEntity(
1169
1645
  { class: tilmeld.User, skipAc: true },
1170
- selector
1646
+ selector,
1171
1647
  );
1172
1648
  if (test != null) {
1173
1649
  return {
@@ -1189,7 +1665,7 @@ export default class User extends AbleObject<UserData> {
1189
1665
  * @returns An object with a boolean 'result' entry and a 'message' entry.
1190
1666
  */
1191
1667
  public async $checkPhone(): Promise<{ result: boolean; message: string }> {
1192
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
1668
+ const tilmeld = enforceTilmeld(this);
1193
1669
  if (this.$data.phone == null || !this.$data.phone.length) {
1194
1670
  return { result: false, message: 'Please specify a phone number.' };
1195
1671
  }
@@ -1211,7 +1687,7 @@ export default class User extends AbleObject<UserData> {
1211
1687
  }
1212
1688
  const test = await this.$nymph.getEntity(
1213
1689
  { class: tilmeld.User, skipAc: true },
1214
- selector
1690
+ selector,
1215
1691
  );
1216
1692
  if (test != null) {
1217
1693
  return { result: false, message: 'Phone number is in use.' };
@@ -1228,7 +1704,7 @@ export default class User extends AbleObject<UserData> {
1228
1704
  password: string;
1229
1705
  additionalData?: { [k: string]: any };
1230
1706
  }): Promise<{ result: boolean; loggedin: boolean; message: string }> {
1231
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
1707
+ let tilmeld = enforceTilmeld(this);
1232
1708
  if (!tilmeld.config.allowRegistration) {
1233
1709
  return {
1234
1710
  result: false,
@@ -1244,21 +1720,6 @@ export default class User extends AbleObject<UserData> {
1244
1720
  };
1245
1721
  }
1246
1722
 
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
1723
  if (!('password' in data) || !data.password.length) {
1263
1724
  return {
1264
1725
  result: false,
@@ -1290,9 +1751,29 @@ export default class User extends AbleObject<UserData> {
1290
1751
  const nymph = this.$nymph;
1291
1752
  const tnymph = await this.$nymph.startTransaction(transaction);
1292
1753
  this.$nymph = tnymph;
1293
- tilmeld = this.$nymph.tilmeld as Tilmeld;
1754
+ tilmeld = enforceTilmeld(this);
1755
+
1756
+ let message = '';
1757
+ let loggedin = false;
1294
1758
 
1295
1759
  try {
1760
+ try {
1761
+ for (let callback of (this.constructor as typeof User)
1762
+ .beforeRegisterCallbacks) {
1763
+ if (callback) {
1764
+ await callback(this, data);
1765
+ }
1766
+ }
1767
+ } catch (e: any) {
1768
+ await tnymph.rollback(transaction);
1769
+ this.$nymph = nymph;
1770
+ return {
1771
+ result: false,
1772
+ loggedin: false,
1773
+ message: e.message,
1774
+ };
1775
+ }
1776
+
1296
1777
  // Add primary group.
1297
1778
  let generatedPrimaryGroup: (Group & GroupData) | null = null;
1298
1779
  if (tilmeld.config.generatePrimary) {
@@ -1307,7 +1788,7 @@ export default class User extends AbleObject<UserData> {
1307
1788
  {
1308
1789
  type: '&',
1309
1790
  equal: ['defaultPrimary', true],
1310
- }
1791
+ },
1311
1792
  );
1312
1793
  if (parent != null) {
1313
1794
  generatedPrimaryGroup.parent = parent;
@@ -1329,7 +1810,7 @@ export default class User extends AbleObject<UserData> {
1329
1810
  {
1330
1811
  type: '&',
1331
1812
  equal: ['defaultPrimary', true],
1332
- }
1813
+ },
1333
1814
  );
1334
1815
  if (group != null) {
1335
1816
  this.$data.group = group;
@@ -1344,7 +1825,7 @@ export default class User extends AbleObject<UserData> {
1344
1825
  {
1345
1826
  type: '&',
1346
1827
  equal: ['unverifiedSecondary', true],
1347
- }
1828
+ },
1348
1829
  );
1349
1830
  } else {
1350
1831
  // Add the default secondaries.
@@ -1353,7 +1834,7 @@ export default class User extends AbleObject<UserData> {
1353
1834
  {
1354
1835
  type: '&',
1355
1836
  equal: ['defaultSecondary', true],
1356
- }
1837
+ },
1357
1838
  );
1358
1839
  }
1359
1840
 
@@ -1403,13 +1884,10 @@ export default class User extends AbleObject<UserData> {
1403
1884
  userPhone: this.$data.phone,
1404
1885
  },
1405
1886
  },
1406
- this
1887
+ this,
1407
1888
  );
1408
1889
  }
1409
1890
 
1410
- let message = '';
1411
- let loggedin = false;
1412
-
1413
1891
  // Save the primary group.
1414
1892
  if (generatedPrimaryGroup != null) {
1415
1893
  generatedPrimaryGroup.user = this;
@@ -1439,7 +1917,7 @@ export default class User extends AbleObject<UserData> {
1439
1917
  tilmeld.config.unverifiedAccess &&
1440
1918
  !madeAdmin
1441
1919
  ) {
1442
- if (!tilmeld.login(this, true)) {
1920
+ if (!(await tilmeld.login(this, true))) {
1443
1921
  throw new Error('An error occurred trying to log you in.');
1444
1922
  }
1445
1923
  message +=
@@ -1448,7 +1926,7 @@ export default class User extends AbleObject<UserData> {
1448
1926
  'registration.';
1449
1927
  loggedin = true;
1450
1928
  } else {
1451
- if (!tilmeld.login(this, true)) {
1929
+ if (!(await tilmeld.login(this, true))) {
1452
1930
  throw new Error('An error occurred trying to log you in.');
1453
1931
  }
1454
1932
  message += "You're now registered and logged in!";
@@ -1464,20 +1942,6 @@ export default class User extends AbleObject<UserData> {
1464
1942
  });
1465
1943
  }
1466
1944
  }
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
1945
  } else {
1482
1946
  await tnymph.rollback(transaction);
1483
1947
  this.$nymph = nymph;
@@ -1492,10 +1956,24 @@ export default class User extends AbleObject<UserData> {
1492
1956
  this.$nymph = nymph;
1493
1957
  throw e;
1494
1958
  }
1959
+
1960
+ try {
1961
+ await tnymph.commit(transaction);
1962
+ this.$nymph = nymph;
1963
+ await this.$nymph.tilmeld?.fillSession(this);
1964
+ } catch (e: any) {
1965
+ throw e;
1966
+ }
1967
+
1968
+ return {
1969
+ result: true,
1970
+ loggedin,
1971
+ message,
1972
+ };
1495
1973
  }
1496
1974
 
1497
1975
  public async $save() {
1498
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
1976
+ let tilmeld = enforceTilmeld(this);
1499
1977
  if (this.$data.username == null || !this.$data.username.trim().length) {
1500
1978
  return false;
1501
1979
  }
@@ -1503,10 +1981,10 @@ export default class User extends AbleObject<UserData> {
1503
1981
  if (
1504
1982
  tilmeld.gatekeeper('tilmeld/admin') &&
1505
1983
  !tilmeld.gatekeeper('system/admin') &&
1506
- this.$gatekeeper('system/admin')
1984
+ this.$data.abilities?.includes('system/admin')
1507
1985
  ) {
1508
1986
  throw new BadDataError(
1509
- "You don't have the authority to modify system admins."
1987
+ "You don't have the authority to modify system admins.",
1510
1988
  );
1511
1989
  }
1512
1990
 
@@ -1624,10 +2102,10 @@ export default class User extends AbleObject<UserData> {
1624
2102
  new Date(
1625
2103
  strtotime(
1626
2104
  '+' + tilmeld.config.emailRateLimit,
1627
- Math.floor(this.$data.emailChangeDate / 1000)
1628
- ) * 1000
2105
+ Math.floor(this.$data.emailChangeDate / 1000),
2106
+ ) * 1000,
1629
2107
  ).toString() +
1630
- ' to change your email address again.'
2108
+ ' to change your email address again.',
1631
2109
  );
1632
2110
  } else {
1633
2111
  if (
@@ -1693,21 +2171,20 @@ export default class User extends AbleObject<UserData> {
1693
2171
  const nymph = this.$nymph;
1694
2172
  const tnymph = await this.$nymph.startTransaction(transaction);
1695
2173
  this.$nymph = tnymph;
1696
- tilmeld = this.$nymph.tilmeld as Tilmeld;
2174
+ tilmeld = enforceTilmeld(this);
1697
2175
 
1698
- if (
1699
- this.$data.group != null &&
1700
- this.$data.group.user != null &&
1701
- this.$is(this.$data.group.user)
1702
- ) {
2176
+ const group = this.$data.group;
2177
+ await group?.$wake();
2178
+ if (group != null && group.user != null && this.$is(group.user)) {
1703
2179
  try {
1704
2180
  // 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();
2181
+ group.groupname = this.$data.username;
2182
+ group.avatar = this.$data.avatar;
2183
+ group.email = this.$data.email;
2184
+ group.name = this.$data.name;
2185
+ group.phone = this.$data.phone;
2186
+ await group.$saveSkipAC();
2187
+ this.$data.group = group;
1711
2188
  } catch (e: any) {
1712
2189
  await tnymph.rollback(transaction);
1713
2190
  this.$nymph = nymph;
@@ -1744,9 +2221,11 @@ export default class User extends AbleObject<UserData> {
1744
2221
  this.$gatekeeperCache = undefined;
1745
2222
  await tnymph.commit(transaction);
1746
2223
 
1747
- if ((nymph.tilmeld as Tilmeld).User.current(true).$is(this)) {
2224
+ const tilmeld = enforceTilmeld(nymph);
2225
+
2226
+ if (tilmeld.User.current(true).$is(this)) {
1748
2227
  // Update the user in the session cache.
1749
- (nymph.tilmeld as Tilmeld).fillSession(this);
2228
+ await tilmeld.fillSession(this);
1750
2229
  }
1751
2230
  } else {
1752
2231
  await tnymph.rollback(transaction);
@@ -1772,16 +2251,16 @@ export default class User extends AbleObject<UserData> {
1772
2251
  }
1773
2252
 
1774
2253
  public async $delete() {
1775
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
2254
+ const tilmeld = enforceTilmeld(this);
1776
2255
  if (!tilmeld.gatekeeper('tilmeld/admin')) {
1777
2256
  throw new BadDataError("You don't have the authority to delete users.");
1778
2257
  }
1779
2258
  if (
1780
2259
  !tilmeld.gatekeeper('system/admin') &&
1781
- this.$gatekeeper('system/admin')
2260
+ this.$data.abilities?.includes('system/admin')
1782
2261
  ) {
1783
2262
  throw new BadDataError(
1784
- "You don't have the authority to delete system admins."
2263
+ "You don't have the authority to delete system admins.",
1785
2264
  );
1786
2265
  }
1787
2266
  if (tilmeld.User.current(true).$is(this)) {
@@ -1792,7 +2271,9 @@ export default class User extends AbleObject<UserData> {
1792
2271
 
1793
2272
  public static on<T extends EventType>(
1794
2273
  event: T,
1795
- callback: T extends 'beforeRegister'
2274
+ callback: T extends 'checkUsername'
2275
+ ? TilmeldCheckUsernameCallback
2276
+ : T extends 'beforeRegister'
1796
2277
  ? TilmeldBeforeRegisterCallback
1797
2278
  : T extends 'afterRegister'
1798
2279
  ? TilmeldAfterRegisterCallback
@@ -1804,9 +2285,11 @@ export default class User extends AbleObject<UserData> {
1804
2285
  ? TilmeldBeforeLogoutCallback
1805
2286
  : T extends 'afterLogout'
1806
2287
  ? TilmeldAfterLogoutCallback
1807
- : never
2288
+ : never,
1808
2289
  ) {
1809
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
2290
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2291
+ ? 'checkUsernameCallbacks'
2292
+ : T extends 'beforeRegister'
1810
2293
  ? 'beforeRegisterCallbacks'
1811
2294
  : T extends 'afterRegister'
1812
2295
  ? 'afterRegisterCallbacks'
@@ -1829,7 +2312,9 @@ export default class User extends AbleObject<UserData> {
1829
2312
 
1830
2313
  public static off<T extends EventType>(
1831
2314
  event: T,
1832
- callback: T extends 'beforeRegister'
2315
+ callback: T extends 'checkUsername'
2316
+ ? TilmeldCheckUsernameCallback
2317
+ : T extends 'beforeRegister'
1833
2318
  ? TilmeldBeforeRegisterCallback
1834
2319
  : T extends 'afterRegister'
1835
2320
  ? TilmeldAfterRegisterCallback
@@ -1841,9 +2326,11 @@ export default class User extends AbleObject<UserData> {
1841
2326
  ? TilmeldBeforeLogoutCallback
1842
2327
  : T extends 'afterLogout'
1843
2328
  ? TilmeldAfterLogoutCallback
1844
- : never
2329
+ : never,
1845
2330
  ) {
1846
- const prop = (event + 'Callbacks') as T extends 'beforeRegister'
2331
+ const prop = (event + 'Callbacks') as T extends 'checkUsername'
2332
+ ? 'checkUsernameCallbacks'
2333
+ : T extends 'beforeRegister'
1847
2334
  ? 'beforeRegisterCallbacks'
1848
2335
  : T extends 'afterRegister'
1849
2336
  ? 'afterRegisterCallbacks'