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

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