@stamhoofd/backend 2.137.4 → 2.138.0

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 (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -0,0 +1,113 @@
1
+ import type { XlsxTransformerColumn, XlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
2
+ import { isXlsxTransformerConcreteColumn } from '@stamhoofd/excel-writer';
3
+ import { EventNotificationType, ExcelExportType, Organization, OrganizationMetaData, OrganizationTag, Platform, PlatformConfig, PlatformMembership, PlatformMembershipMemberDetails, PlatformMembershipOrganizationDetails, PlatformMembershipType, RecordCategory, RecordSettings, RecordType, TranslatedString } from '@stamhoofd/structures';
4
+ import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
5
+ import './index.js';
6
+
7
+ /**
8
+ * The sheets are built per export request, so every callback should read the platform it was built
9
+ * with instead of a process wide singleton.
10
+ */
11
+ describe('Excel loaders platform argument', () => {
12
+ function getColumns(type: ExcelExportType, platform: Platform, sheetId: string): XlsxTransformerColumn<unknown>[] {
13
+ const loader = ExportToExcelEndpoint.loaders.get(type);
14
+
15
+ if (!loader) {
16
+ throw new Error('Loader ' + type + ' not registered');
17
+ }
18
+
19
+ const sheet = loader.getSheets(platform).find(s => s.id === sheetId);
20
+
21
+ if (!sheet) {
22
+ throw new Error('Sheet ' + sheetId + ' not found');
23
+ }
24
+
25
+ return sheet.columns;
26
+ }
27
+
28
+ function getColumn(type: ExcelExportType, platform: Platform, sheetId: string, columnId: string): XlsxTransformerConcreteColumn<unknown> {
29
+ const column = getColumns(type, platform, sheetId).find(c => isXlsxTransformerConcreteColumn(c) && c.id === columnId);
30
+
31
+ if (!column || !isXlsxTransformerConcreteColumn(column)) {
32
+ throw new Error('Column ' + columnId + ' not found');
33
+ }
34
+
35
+ return column;
36
+ }
37
+
38
+ function createPlatformWithTag(tagName: string) {
39
+ return Platform.create({
40
+ config: PlatformConfig.create({
41
+ tags: [
42
+ OrganizationTag.create({ id: 'tag-1', name: tagName }),
43
+ ],
44
+ }),
45
+ });
46
+ }
47
+
48
+ it('reads the organization tags from the platform the sheets were built with', () => {
49
+ const organization = Organization.create({
50
+ meta: OrganizationMetaData.create({ tags: ['tag-1'] }),
51
+ });
52
+
53
+ const first = getColumn(ExcelExportType.Organizations, createPlatformWithTag('Gewest Noord'), 'organizations', 'tags');
54
+ expect(first.getValue(organization).value).toBe('Gewest Noord');
55
+
56
+ // Building the sheets again with another platform should not reuse the first platform
57
+ const second = getColumn(ExcelExportType.Organizations, createPlatformWithTag('Gewest Zuid'), 'organizations', 'tags');
58
+ expect(second.getValue(organization).value).toBe('Gewest Zuid');
59
+ });
60
+
61
+ it('reads the membership type from the platform the sheets were built with', () => {
62
+ const platform = Platform.create({
63
+ config: PlatformConfig.create({
64
+ membershipTypes: [
65
+ PlatformMembershipType.create({ id: 'type-1', name: 'Aansluiting' }),
66
+ ],
67
+ }),
68
+ });
69
+
70
+ const membership = PlatformMembership.create({
71
+ memberId: 'member-1',
72
+ membershipTypeId: 'type-1',
73
+ organizationId: 'organization-1',
74
+ periodId: 'period-1',
75
+ member: PlatformMembershipMemberDetails.create({ firstName: 'John', lastName: 'Doe', memberNumber: null }),
76
+ organization: PlatformMembershipOrganizationDetails.create({}),
77
+ balanceItem: null,
78
+ });
79
+
80
+ const column = getColumn(ExcelExportType.PlatformMemberships, platform, 'platform-memberships', 'type');
81
+ expect(column.getValue(membership).value).toBe('Aansluiting');
82
+ });
83
+
84
+ it('reads the record categories of an event notification from the platform the sheets were built with', () => {
85
+ const platform = Platform.create({
86
+ config: PlatformConfig.create({
87
+ eventNotificationTypes: [
88
+ EventNotificationType.create({
89
+ recordCategories: [
90
+ RecordCategory.create({
91
+ name: new TranslatedString('Kamp'),
92
+ records: [
93
+ RecordSettings.create({
94
+ id: 'record-1',
95
+ name: new TranslatedString('Aantal deelnemers'),
96
+ type: RecordType.Text,
97
+ }),
98
+ ],
99
+ }),
100
+ ],
101
+ }),
102
+ ],
103
+ }),
104
+ });
105
+
106
+ const columns = getColumns(ExcelExportType.EventNotifications, platform, 'event-notifications');
107
+ const matched = columns.flatMap(c => isXlsxTransformerConcreteColumn(c) ? [] : (c.match('recordAnswers.record-1') ?? []));
108
+
109
+ expect(matched).toHaveLength(1);
110
+ expect(matched[0].name).toBe('Aantal deelnemers');
111
+ expect(matched[0].category).toBe('Kamp');
112
+ });
113
+ });
@@ -19,7 +19,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.ReceivableBalances, {
19
19
  ...data,
20
20
  });
21
21
  },
22
- sheets: [
22
+ getSheets: () => [
23
23
  {
24
24
  id: 'receivableBalances',
25
25
  name: $t(`%99`),
@@ -1,17 +1,17 @@
1
1
  import type { XlsxTransformerSheet } from '@stamhoofd/excel-writer';
2
2
  import { XlsxBuiltInNumberFormat } from '@stamhoofd/excel-writer';
3
3
  import { Platform } from '@stamhoofd/models';
4
- import type { LimitedFilteredRequest, PlatformMember } from '@stamhoofd/structures';
5
- import { ExcelExportType, getGroupTypeName, PlatformRegistration, Platform as PlatformStruct, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
4
+ import type { LimitedFilteredRequest, PlatformMember, Platform as PlatformStruct } from '@stamhoofd/structures';
5
+ import { ExcelExportType, getGroupTypeName, PlatformRegistration, UnencodeablePaginatedResponse } from '@stamhoofd/structures';
6
6
  import { ExportToExcelEndpoint } from '../endpoints/global/files/ExportToExcelEndpoint.js';
7
7
  import { GetRegistrationsEndpoint } from '../endpoints/global/registration/GetRegistrationsEndpoint.js';
8
8
  import { AuthenticatedStructures } from '../helpers/AuthenticatedStructures.js';
9
9
  import { Context } from '../helpers/Context.js';
10
10
  import { XlsxTransformerColumnHelper } from '../helpers/XlsxTransformerColumnHelper.js';
11
- import { baseMemberColumns } from './members.js';
11
+ import { getBaseMemberColumns } from './members.js';
12
12
 
13
13
  // Assign to a typed variable to assure we have correct type checking in place
14
- const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
14
+ const getSheet = (platform: PlatformStruct): XlsxTransformerSheet<PlatformMember, PlatformRegistration> => ({
15
15
  id: 'registrations',
16
16
  name: $t('%1EI'),
17
17
  columns: [
@@ -294,7 +294,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
294
294
  ];
295
295
  },
296
296
  },
297
- ...baseMemberColumns.map(column => XlsxTransformerColumnHelper.transformColumnForProperty({
297
+ ...getBaseMemberColumns(platform).map(column => XlsxTransformerColumnHelper.transformColumnForProperty({
298
298
  column,
299
299
  key: 'member',
300
300
  getPropertyValue: (registration: PlatformRegistration) => registration.member,
@@ -341,7 +341,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
341
341
  };
342
342
  }
343
343
  return {
344
- value: PlatformStruct.shared.config.defaultAgeGroups.find(g => g.id === defaultAgeGroupId)?.name ?? $t(`%wJ`),
344
+ value: platform.config.defaultAgeGroups.find(g => g.id === defaultAgeGroupId)?.name ?? $t(`%wJ`),
345
345
  };
346
346
  },
347
347
  },
@@ -356,7 +356,7 @@ const sheet: XlsxTransformerSheet<PlatformMember, PlatformRegistration> = {
356
356
  },
357
357
  },
358
358
  ],
359
- };
359
+ });
360
360
 
361
361
  ExportToExcelEndpoint.loaders.set(ExcelExportType.Registrations, {
362
362
  fetch: async (query: LimitedFilteredRequest) => {
@@ -370,7 +370,7 @@ ExportToExcelEndpoint.loaders.set(ExcelExportType.Registrations, {
370
370
  next: result.next,
371
371
  });
372
372
  },
373
- sheets: [
374
- sheet,
373
+ getSheets: platform => [
374
+ getSheet(platform),
375
375
  ],
376
376
  });
@@ -794,7 +794,7 @@ export class AdminPermissionChecker {
794
794
 
795
795
  // Check if this user has permissions for the current scoped organization
796
796
  if (this.organization && await this.hasFullAccess(this.organization.id)) {
797
- if (user.permissions?.forOrganization(this.organization)) {
797
+ if (user.permissions?.forOrganization(this.organization, this.platform, { inheritFromPlatform: false })) {
798
798
  return true;
799
799
  }
800
800
  }
@@ -1,14 +1,15 @@
1
1
  import { SimpleError } from '@simonbackx/simple-errors';
2
2
  import type { AuditLog, Document, EventNotification, MemberWithUsersRegistrationsAndGroups, Order, Ticket } from '@stamhoofd/models';
3
- import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
3
+ import { BalanceItem, CachedBalance, Event, Group, Invoice, Member, MemberPlatformMembership, MemberResponsibilityRecord, Organization, OrganizationRegistrationPeriod, Payment, Platform as PlatformModel, Registration, RegistrationInvitation, RegistrationPeriod, User, Webshop } from '@stamhoofd/models';
4
4
  import type { PaymentGeneral } from '@stamhoofd/structures';
5
5
  import { BaseOrganization, getAppHost, OrganizationPrivateMetaData } from '@stamhoofd/structures';
6
- import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel, Platform, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationInvitation as RegistrationInvitationStruct, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
6
+ import { Payment as PaymentStruct, AuditLogReplacement, AuditLogReplacementType, AuditLog as AuditLogStruct, BalanceItem as BalanceItemStruct, DetailedReceivableBalance, Document as DocumentStruct, EventNotification as EventNotificationStruct, Event as EventStruct, GenericBalance, Group as GroupStruct, GroupType, InvitationGroupData, InvitationMemberData, InvoicedBalanceItem, InvoiceStruct, MemberPlatformMembership as MemberPlatformMembershipStruct, MemberRegistrationInvitation, MembersBlob, MemberWithRegistrationsBlob, NamedObject, OrganizationRegistrationPeriod as OrganizationRegistrationPeriodStruct, Organization as OrganizationStruct, PaymentCustomer, PermissionLevel, PrivateOrder, PrivateWebshop, ReceivableBalanceObject, ReceivableBalanceObjectContact, ReceivableBalance as ReceivableBalanceStruct, ReceivableBalanceType, RegistrationInvitation as RegistrationInvitationStruct, RegistrationsBlob, RegistrationWithMemberBlob, TicketPrivate, UserWithMembers, WebshopPreview, Webshop as WebshopStruct } from '@stamhoofd/structures';
7
7
  import { Sorter } from '@stamhoofd/utility';
8
8
 
9
9
  import { SQL } from '@stamhoofd/sql';
10
10
  import { Formatter } from '@stamhoofd/utility';
11
11
  import { Context } from './Context.js';
12
+ import { TwoFactorHelper } from './TwoFactorHelper.js';
12
13
 
13
14
  /**
14
15
  * Builds authenticated structures for the current user
@@ -425,7 +426,7 @@ export class AuthenticatedStructures {
425
426
  }
426
427
  }
427
428
 
428
- return UserWithMembers.create({
429
+ const struct = UserWithMembers.create({
429
430
  ...user,
430
431
  hasAccount: user.hasAccount(),
431
432
  hasPassword: user.hasPasswordBasedAccount(),
@@ -433,6 +434,8 @@ export class AuthenticatedStructures {
433
434
  // Always include the current context organization - because it is possible we switch organization and we don't want to refetch every time
434
435
  members: await this.membersBlob(filtered, true, user),
435
436
  });
437
+ await TwoFactorHelper.fillTwoFactorStatus([struct]);
438
+ return struct;
436
439
  }
437
440
 
438
441
  /**
@@ -453,6 +456,8 @@ export class AuthenticatedStructures {
453
456
  }));
454
457
  }
455
458
 
459
+ await TwoFactorHelper.fillTwoFactorStatus(structs);
460
+
456
461
  return structs;
457
462
  }
458
463
 
@@ -465,11 +470,12 @@ export class AuthenticatedStructures {
465
470
  return MembersBlob.create({ members: [], organizations: [] });
466
471
  }
467
472
 
473
+ const platform = await PlatformModel.getSharedStruct();
468
474
  const organizations = new Map<string, Organization>();
469
475
  const relevantPeriodIds = Formatter.uniqueArray([
470
- Platform.shared.period.previousPeriodId,
471
- Platform.shared.period.id,
472
- Platform.shared.period.nextPeriodId,
476
+ platform.period.previousPeriodId,
477
+ platform.period.id,
478
+ platform.period.nextPeriodId,
473
479
  Context.organization?.periodId ?? null,
474
480
  ].filter(id => id !== null)) as string[];
475
481
 
@@ -520,7 +526,7 @@ export class AuthenticatedStructures {
520
526
  organizationIds.push(STAMHOOFD.singleOrganization);
521
527
  }
522
528
 
523
- const membershipOrganizationId = Platform.shared.membershipOrganizationId;
529
+ const membershipOrganizationId = platform.membershipOrganizationId;
524
530
  if (membershipOrganizationId && Context.auth.hasSomePlatformAccess()) {
525
531
  if (await Context.auth.hasSomeAccess(membershipOrganizationId)) {
526
532
  organizationIds.push(membershipOrganizationId);
@@ -696,6 +702,9 @@ export class AuthenticatedStructures {
696
702
  );
697
703
  }
698
704
 
705
+ // After filterMemberData, so we only query for the users we actually return.
706
+ await TwoFactorHelper.fillTwoFactorStatus(memberBlobs.flatMap(b => b.users));
707
+
699
708
  for (const blob of memberBlobs) {
700
709
  blob.responsibilities = responsibilities.filter(r => r.memberId == blob.id).map((r) => {
701
710
  const group = allGroups.get(r.groupId ?? '') ?? null;
@@ -1007,6 +1016,7 @@ export class AuthenticatedStructures {
1007
1016
  ...balances.filter(b => b.objectType === ReceivableBalanceType.user || b.objectType === ReceivableBalanceType.userWithoutMembers).map(b => b.objectId),
1008
1017
  ]);
1009
1018
  const users = userIds.length > 0 ? await User.getByIDs(...userIds) : [];
1019
+ const platform = await PlatformModel.getSharedStruct();
1010
1020
 
1011
1021
  const result: { balance: CachedBalance; object: ReceivableBalanceObject }[] = [];
1012
1022
 
@@ -1131,7 +1141,7 @@ export class AuthenticatedStructures {
1131
1141
  } else if (balance.objectType === ReceivableBalanceType.user || balance.objectType === ReceivableBalanceType.userWithoutMembers) {
1132
1142
  const user = users.find(m => m.id === balance.objectId) ?? null;
1133
1143
  if (user) {
1134
- const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + getAppHost('registration', Context.organization, user.permissions?.forOrganization(Context.organization)?.isEmpty === false) : '';
1144
+ const url = Context.organization && Context.organization.id === balance.organizationId ? 'https://' + getAppHost('registration', Context.organization, user.permissions?.forOrganization(Context.organization, platform, { inheritFromPlatform: false })?.isEmpty === false) : '';
1135
1145
  object = ReceivableBalanceObject.create({
1136
1146
  id: balance.objectId,
1137
1147
  name: user.name || user.email,
@@ -1193,6 +1203,7 @@ export class AuthenticatedStructures {
1193
1203
  const users = await User.getByIDs(...userIds);
1194
1204
  const organizationsIds = Formatter.uniqueArray(logs.map(l => l.organizationId).filter(id => id !== null));
1195
1205
  const organizations = await Organization.getByIDs(...organizationsIds);
1206
+ const platform = await PlatformModel.getSharedStruct();
1196
1207
 
1197
1208
  for (const log of logs) {
1198
1209
  const user = log.userId ? (users.find(u => u.id === log.userId) ?? null) : null;
@@ -1200,10 +1211,10 @@ export class AuthenticatedStructures {
1200
1211
 
1201
1212
  if (user) {
1202
1213
  if (!await Context.auth.canAccessUser(user)) {
1203
- if (user.permissions?.platform !== null) {
1214
+ if (user.permissions?.forPlatform(platform) !== null) {
1204
1215
  userStruct = NamedObject.create({
1205
1216
  id: '',
1206
- name: $t(`%wi`) + ' ' + Platform.shared.config.name,
1217
+ name: $t(`%wi`) + ' ' + platform.config.name,
1207
1218
  });
1208
1219
  } else {
1209
1220
  userStruct = NamedObject.create({
@@ -1,14 +1,14 @@
1
1
  import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { isSimpleError, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { I18n } from '@stamhoofd/backend-i18n';
4
- import type { User } from '@stamhoofd/models';
5
- import { Organization, Platform, RateLimiter, Token } from '@stamhoofd/models';
4
+ import { MFAToken, Organization, Platform, RateLimiter, Token, User } from '@stamhoofd/models';
6
5
  import { AsyncLocalStorage } from 'async_hooks';
7
6
 
8
7
  import type { Decoder } from '@simonbackx/simple-encoding';
9
8
  import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
10
9
  import { ApiUserRateLimits } from '@stamhoofd/structures';
11
10
  import { AdminPermissionChecker } from './AdminPermissionChecker.js';
11
+ import { TwoFactorHelper } from './TwoFactorHelper.js';
12
12
 
13
13
  export const apiUserRateLimiter = new RateLimiter({
14
14
  limits: [
@@ -180,7 +180,7 @@ export class ContextInstance {
180
180
  if (STAMHOOFD.userMode === 'platform') {
181
181
  return null;
182
182
  }
183
- return await this.setOrganizationScope(options);
183
+ return await this.setOptionalOrganizationScope(options);
184
184
  }
185
185
 
186
186
  async setOrganizationScope(options?: { willAuthenticate?: boolean }) {
@@ -223,7 +223,24 @@ export class ContextInstance {
223
223
  }
224
224
  }
225
225
 
226
- async authenticate({ allowWithoutAccount = false, allowUnscoped = false }: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
226
+ /**
227
+ * Identify the caller from the Authorization header without ever throwing: returns
228
+ * null when the request is unauthenticated or carries an invalid/expired token.
229
+ * Use this to give an already signed in user a smoother experience, never to grant
230
+ * access (there is no error to react to).
231
+ */
232
+ async optionalAuthenticatedUser(): Promise<User | null> {
233
+ try {
234
+ const { user } = await this.authenticate({ allowWithoutAccount: true });
235
+ return user;
236
+ } catch (e) {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ async authenticate(options: { allowMFASetupToken: true; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }>;
242
+ async authenticate(options?: { allowMFASetupToken?: false | undefined; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token }>;
243
+ async authenticate({ allowWithoutAccount = false, allowUnscoped = false, allowMFASetupToken = false }: { allowMFASetupToken?: boolean; allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }> {
227
244
  let header = this.request.headers.authorization;
228
245
 
229
246
  if (!header && this.request.method === 'POST') {
@@ -244,6 +261,12 @@ export class ContextInstance {
244
261
  }
245
262
 
246
263
  if (!header.startsWith('Bearer ')) {
264
+ if (allowMFASetupToken) {
265
+ const result = await this.authenticateMFASetup();
266
+ if (result) {
267
+ return result;
268
+ }
269
+ }
247
270
  throw new SimpleError({
248
271
  code: 'not_supported_authentication',
249
272
  message: 'Authentication method not supported. Please authenticate with OAuth2',
@@ -320,6 +343,93 @@ export class ContextInstance {
320
343
  return { user, token };
321
344
  }
322
345
 
346
+ /**
347
+ * Like authenticate(), but additionally requires the access token to be "fresh"
348
+ * (minted by a real authentication within the FRESH_WINDOW, not via a refresh).
349
+ * Used to gate sensitive actions such as managing 2FA methods.
350
+ */
351
+ async authenticateFresh(options: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
352
+ const result = await this.authenticate(options);
353
+ if (!result.token.isFresh()) {
354
+ throw new SimpleError({
355
+ code: 'require_fresh_auth',
356
+ message: 'A recent authentication is required for this action',
357
+ human: $t('%Zgs'),
358
+ statusCode: 403,
359
+ });
360
+ }
361
+ return result;
362
+ }
363
+
364
+ /**
365
+ * Authorize a 2FA enrollment/management action. Accepts either a fresh full session
366
+ * (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
367
+ * during forced enrollment, before a session exists.
368
+ */
369
+ async authenticateMFASetup(): Promise<{ user: User; setupToken: MFAToken } | null> {
370
+ const header = this.request.headers.authorization;
371
+ if (header && header.startsWith('MFASetup ')) {
372
+ const raw = header.substring('MFASetup '.length);
373
+ const setupToken = await MFAToken.getValid(raw, 'setup');
374
+ if (!setupToken) {
375
+ throw new SimpleError({
376
+ code: 'mfa_setup_expired',
377
+ message: 'The MFA setup session is invalid or expired',
378
+ human: $t('%ZhJ'),
379
+ statusCode: 401,
380
+ });
381
+ }
382
+ const user = await User.getByID(setupToken.userId);
383
+ if (!user) {
384
+ throw new SimpleError({
385
+ code: 'mfa_setup_expired',
386
+ message: 'The MFA setup session is invalid or expired',
387
+ human: $t('%ZhJ'),
388
+ statusCode: 401,
389
+ });
390
+ }
391
+
392
+ // A setup token is issued on the strength of a password alone, to bootstrap a
393
+ // *first* factor. If the user enrolled one in the meantime (e.g. from another
394
+ // device), it must stop being worth a session: otherwise someone who only
395
+ // knows the password keeps a 15 minute window in which they can enroll a
396
+ // factor of their own and sign in, even though the account is now protected.
397
+ if (await TwoFactorHelper.userHasFactors(user.id)) {
398
+ await setupToken.consume();
399
+ throw new SimpleError({
400
+ code: 'mfa_setup_expired',
401
+ message: 'The MFA setup session is no longer valid: the user already has a second factor',
402
+ human: $t('%ZhJ'),
403
+ statusCode: 401,
404
+ });
405
+ }
406
+
407
+ this.user = user;
408
+ await this.insecurelyAuthenticateAs(user);
409
+ return { user, setupToken };
410
+ }
411
+
412
+ return null;
413
+ }
414
+
415
+ /**
416
+ * Authorize a 2FA enrollment/management action. Accepts either a fresh full session
417
+ * (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
418
+ * during forced enrollment, before a session exists.
419
+ *
420
+ * Exactly one of `setupToken` / `token` is set: `token` is the session the request was
421
+ * made with, which enrollment keeps alive while signing out the user's other sessions.
422
+ */
423
+ async authenticateMFAEnrollment(): Promise<{ user: User; setupToken: MFAToken | null; token: Token | null }> {
424
+ const mfa = await this.authenticateMFASetup();
425
+ if (mfa) {
426
+ return { ...mfa, token: null };
427
+ }
428
+
429
+ const { user, token } = await this.authenticateFresh();
430
+ return { user, setupToken: null, token };
431
+ }
432
+
323
433
  async insecurelyAuthenticateAs(user: User) {
324
434
  this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
325
435