@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.
- package/package.json +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
|
@@ -585,7 +585,7 @@ export class PatchOrganizationMembersEndpoint extends Endpoint<Params, Query, Bo
|
|
|
585
585
|
membership.locked = false;
|
|
586
586
|
|
|
587
587
|
// Correct price and dates
|
|
588
|
-
await
|
|
588
|
+
await PlatformMembershipService.calculatePrice(membership, member);
|
|
589
589
|
|
|
590
590
|
const incompatible = await MemberPlatformMembership.select()
|
|
591
591
|
.where('memberId', member.id)
|
|
@@ -3,7 +3,8 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
|
3
3
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
5
5
|
import type { Organization } from '@stamhoofd/models';
|
|
6
|
-
import { AuditLog, Member, Platform, RateLimiter
|
|
6
|
+
import { AuditLog, Member, Platform, RateLimiter } from '@stamhoofd/models';
|
|
7
|
+
import { sendEmailTemplate } from '../../../helpers/EmailBuilder.js';
|
|
7
8
|
import { AuditLogReplacement, AuditLogReplacementType, AuditLogSource, AuditLogType, EmailTemplateType, Recipient, Replacement, SecurityCodeSendMethod, SendMemberSecurityCodeRequest, SendMemberSecurityCodeResponse } from '@stamhoofd/structures';
|
|
8
9
|
import type { Country } from '@stamhoofd/types/Country';
|
|
9
10
|
import { Formatter } from '@stamhoofd/utility';
|
|
@@ -12,6 +12,7 @@ import { Formatter } from '@stamhoofd/utility';
|
|
|
12
12
|
import { v4 as uuidv4 } from 'uuid';
|
|
13
13
|
import { AuditLogService } from '../../../services/AuditLogService.js';
|
|
14
14
|
import { ReferralService } from '../../../services/ReferralService.js';
|
|
15
|
+
import { VerificationCodeService } from '../../../services/VerificationCodeService.js';
|
|
15
16
|
import { AuthenticatedStructures } from '../../../helpers/AuthenticatedStructures.js';
|
|
16
17
|
import { Context } from '../../../helpers/Context.js';
|
|
17
18
|
|
|
@@ -160,7 +161,7 @@ export class CreateOrganizationEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
160
161
|
await AuditLog.update().where('type', AuditLogType.OrganizationAdded).where('objectId', organization.id).set('userId', user.id).set('source', AuditLogSource.User).update();
|
|
161
162
|
|
|
162
163
|
const code = await EmailVerificationCode.createFor(user, user.email);
|
|
163
|
-
|
|
164
|
+
VerificationCodeService.send(code, user, organization, request.i18n).catch(console.error);
|
|
164
165
|
|
|
165
166
|
for (const email of delayEmails) {
|
|
166
167
|
Email.send({
|
|
@@ -4,6 +4,7 @@ import { User } from '@stamhoofd/models';
|
|
|
4
4
|
import { OrganizationAdmins } from '@stamhoofd/structures';
|
|
5
5
|
|
|
6
6
|
import { Context } from '../../../helpers/Context.js';
|
|
7
|
+
import { TwoFactorHelper } from '../../../helpers/TwoFactorHelper.js';
|
|
7
8
|
|
|
8
9
|
type Params = Record<string, never>;
|
|
9
10
|
type Query = undefined;
|
|
@@ -34,10 +35,12 @@ export class GetPlatformAdminsEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
34
35
|
|
|
35
36
|
// Get all admins
|
|
36
37
|
const admins = await User.getPlatformAdmins();
|
|
38
|
+
const users = admins.map(user => user.getStructure());
|
|
39
|
+
await TwoFactorHelper.fillTwoFactorStatus(users);
|
|
37
40
|
|
|
38
41
|
return new Response(
|
|
39
42
|
OrganizationAdmins.create({
|
|
40
|
-
users
|
|
43
|
+
users,
|
|
41
44
|
}),
|
|
42
45
|
);
|
|
43
46
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import type { Organization } from '@stamhoofd/models';
|
|
3
3
|
import { OrganizationFactory, Platform, RegistrationPeriod, RegistrationPeriodFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
4
|
-
import { PermissionLevel, Permissions, PlatformConfig, Platform as PlatformStruct, Version } from '@stamhoofd/structures';
|
|
4
|
+
import { PermissionLevel, Permissions, PlatformConfig, PlatformPrivateConfig, Platform as PlatformStruct, Version } from '@stamhoofd/structures';
|
|
5
5
|
|
|
6
6
|
import type { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
|
|
7
7
|
import { TestUtils } from '@stamhoofd/test-utils';
|
|
@@ -22,6 +22,40 @@ describe('Endpoint.PatchPlatform', () => {
|
|
|
22
22
|
return await testServer.test(endpoint, request);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
const setPlatformRequiresTwoFactor = async (requireTwoFactor: boolean) => {
|
|
26
|
+
const platform = await Platform.getForEditing();
|
|
27
|
+
platform.privateConfig.requireTwoFactor = requireTwoFactor;
|
|
28
|
+
await platform.save();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('Should save whether two-factor authentication is required for platform admins', async () => {
|
|
32
|
+
const organization = await new OrganizationFactory({ }).create();
|
|
33
|
+
|
|
34
|
+
const admin = await new UserFactory({
|
|
35
|
+
globalPermissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
36
|
+
}).create();
|
|
37
|
+
const token = await Token.createToken(admin);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const response = await patchPlatform({
|
|
41
|
+
patch: PlatformStruct.patch({
|
|
42
|
+
privateConfig: PlatformPrivateConfig.patch({
|
|
43
|
+
requireTwoFactor: true,
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
organization,
|
|
47
|
+
token,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(response.body.privateConfig?.requireTwoFactor).toBe(true);
|
|
51
|
+
expect((await Platform.getForEditing()).privateConfig.requireTwoFactor).toBe(true);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
// The platform row is shared by every test file
|
|
55
|
+
await setPlatformRequiresTwoFactor(false);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
25
59
|
describe('userMode organization', () => {
|
|
26
60
|
beforeEach(async () => {
|
|
27
61
|
TestUtils.setEnvironment('userMode', 'organization');
|
|
@@ -82,6 +82,14 @@ export class PatchPlatformEndpoint extends Endpoint<
|
|
|
82
82
|
request.body.privateConfig.emails,
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
if (request.body.privateConfig.requireTwoFactor !== undefined) {
|
|
87
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
88
|
+
throw Context.auth.error();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
platform.privateConfig.requireTwoFactor = request.body.privateConfig.requireTwoFactor;
|
|
92
|
+
}
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
let shouldUpdateSetupSteps = false;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization } from '@stamhoofd/models';
|
|
3
|
+
import { OrganizationFactory, Token, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
5
|
+
import { STExpect, TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
|
|
7
|
+
import { testServer } from '../../../../tests/helpers/TestServer.js';
|
|
8
|
+
import { SignOutPlatformAdminsEndpoint } from './SignOutPlatformAdminsEndpoint.js';
|
|
9
|
+
|
|
10
|
+
describe('Endpoint.SignOutPlatformAdmins', () => {
|
|
11
|
+
const endpoint = new SignOutPlatformAdminsEndpoint();
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
TestUtils.setEnvironment('userMode', 'platform');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const signOut = async (organization: Organization, token: Token) => {
|
|
18
|
+
const request = Request.buildJson('POST', '/platform/admins/sign-out', organization.getApiHost());
|
|
19
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
20
|
+
return await testServer.test(endpoint, request);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const createPlatformAdmin = async (level = PermissionLevel.Full) => {
|
|
24
|
+
return await new UserFactory({
|
|
25
|
+
globalPermissions: Permissions.create({ level }),
|
|
26
|
+
}).create();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const tokenExists = async (token: Token) => {
|
|
30
|
+
return !!(await Token.getByAccessToken(token.accessToken, true));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
test('all sessions of all platform admins are signed out, except the one making the request', async () => {
|
|
34
|
+
const organization = await new OrganizationFactory({}).create();
|
|
35
|
+
|
|
36
|
+
const admin = await createPlatformAdmin();
|
|
37
|
+
const otherAdmin = await createPlatformAdmin();
|
|
38
|
+
|
|
39
|
+
// An admin of an organization is not a platform admin
|
|
40
|
+
const organizationAdmin = await new UserFactory({
|
|
41
|
+
organization,
|
|
42
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
43
|
+
}).create();
|
|
44
|
+
organizationAdmin.organizationId = null;
|
|
45
|
+
await organizationAdmin.save();
|
|
46
|
+
|
|
47
|
+
const currentToken = await Token.createToken(admin, new Date());
|
|
48
|
+
const secondDeviceOfAdmin = await Token.createToken(admin, new Date());
|
|
49
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
50
|
+
const organizationAdminToken = await Token.createToken(organizationAdmin, new Date());
|
|
51
|
+
|
|
52
|
+
const response = await signOut(organization, currentToken);
|
|
53
|
+
|
|
54
|
+
expect(response.body.count).toBe(2);
|
|
55
|
+
expect(await tokenExists(currentToken)).toBe(true);
|
|
56
|
+
expect(await tokenExists(secondDeviceOfAdmin)).toBe(false);
|
|
57
|
+
expect(await tokenExists(otherAdminToken)).toBe(false);
|
|
58
|
+
expect(await tokenExists(organizationAdminToken)).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a platform admin without full access cannot sign out the other admins', async () => {
|
|
62
|
+
const organization = await new OrganizationFactory({}).create();
|
|
63
|
+
|
|
64
|
+
const admin = await createPlatformAdmin(PermissionLevel.Write);
|
|
65
|
+
const otherAdmin = await createPlatformAdmin();
|
|
66
|
+
const otherAdminToken = await Token.createToken(otherAdmin, new Date());
|
|
67
|
+
|
|
68
|
+
await expect(signOut(organization, await Token.createToken(admin, new Date()))).rejects.toThrow(STExpect.simpleError({
|
|
69
|
+
code: 'permission_denied',
|
|
70
|
+
}));
|
|
71
|
+
expect(await tokenExists(otherAdminToken)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('an admin of an organization cannot sign out the platform admins', async () => {
|
|
75
|
+
const organization = await new OrganizationFactory({}).create();
|
|
76
|
+
|
|
77
|
+
const organizationAdmin = await new UserFactory({
|
|
78
|
+
organization,
|
|
79
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
80
|
+
}).create();
|
|
81
|
+
organizationAdmin.organizationId = null;
|
|
82
|
+
await organizationAdmin.save();
|
|
83
|
+
|
|
84
|
+
const platformAdmin = await createPlatformAdmin();
|
|
85
|
+
const platformAdminToken = await Token.createToken(platformAdmin, new Date());
|
|
86
|
+
|
|
87
|
+
await expect(signOut(organization, await Token.createToken(organizationAdmin, new Date()))).rejects.toThrow(STExpect.simpleError({
|
|
88
|
+
code: 'permission_denied',
|
|
89
|
+
}));
|
|
90
|
+
expect(await tokenExists(platformAdminToken)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
3
|
+
import { CountResponse } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
import { Context } from '../../../helpers/Context.js';
|
|
6
|
+
import { AdminSessionService } from '../../../services/AdminSessionService.js';
|
|
7
|
+
|
|
8
|
+
type Params = Record<string, never>;
|
|
9
|
+
type Query = undefined;
|
|
10
|
+
type Body = undefined;
|
|
11
|
+
type ResponseBody = CountResponse;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* End the sessions of all administrators of the platform, so they have to sign in again.
|
|
15
|
+
* Used after making two-factor authentication required, which is only enforced on new
|
|
16
|
+
* logins.
|
|
17
|
+
*/
|
|
18
|
+
export class SignOutPlatformAdminsEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
19
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
20
|
+
if (request.method !== 'POST') {
|
|
21
|
+
return [false];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const params = Endpoint.parseParameters(request.url, '/platform/admins/sign-out', {});
|
|
25
|
+
|
|
26
|
+
if (params) {
|
|
27
|
+
return [true, params as Params];
|
|
28
|
+
}
|
|
29
|
+
return [false];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async handle(_: DecodedRequest<Params, Query, Body>) {
|
|
33
|
+
const { token } = await Context.authenticate();
|
|
34
|
+
|
|
35
|
+
if (!Context.auth.canManagePlatformAdmins()) {
|
|
36
|
+
throw Context.auth.error();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const count = await AdminSessionService.signOutPlatformAdmins(token.accessToken);
|
|
40
|
+
|
|
41
|
+
return new Response(CountResponse.create({ count }));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -4,6 +4,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
4
4
|
import { DocumentTemplate } from '@stamhoofd/models';
|
|
5
5
|
|
|
6
6
|
import { Context } from '../../../../helpers/Context.js';
|
|
7
|
+
import { DocumentRenderService } from '../../../../services/DocumentRenderService.js';
|
|
7
8
|
|
|
8
9
|
type Params = { id: string };
|
|
9
10
|
type Query = undefined;
|
|
@@ -42,7 +43,7 @@ export class GetDocumentTemplateXMLEndpoint extends Endpoint<Params, Query, Body
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
// Update documents
|
|
45
|
-
const xml = await
|
|
46
|
+
const xml = await DocumentRenderService.getRenderedXml(template, organization);
|
|
46
47
|
if (!xml) {
|
|
47
48
|
throw new SimpleError({
|
|
48
49
|
code: 'failed_generating',
|
|
@@ -166,6 +166,7 @@ export class PatchOrganizationEndpoint extends Endpoint<Params, Query, Body, Res
|
|
|
166
166
|
organization.privateMeta.balanceNotificationSettings = patchObject(organization.privateMeta.balanceNotificationSettings, request.body.privateMeta.balanceNotificationSettings);
|
|
167
167
|
organization.privateMeta.invoiceSettings = patchObject(organization.privateMeta.invoiceSettings, request.body.privateMeta.invoiceSettings);
|
|
168
168
|
organization.privateMeta.recordAnswers = request.body.privateMeta.recordAnswers.applyTo(organization.privateMeta.recordAnswers);
|
|
169
|
+
organization.privateMeta.requireTwoFactor = patchObject(organization.privateMeta.requireTwoFactor, request.body.privateMeta.requireTwoFactor);
|
|
169
170
|
|
|
170
171
|
if (request.body.privateMeta.responsibilities || request.body.privateMeta.roles) {
|
|
171
172
|
shouldUpdateUserPermissions = true;
|
|
@@ -9,6 +9,7 @@ import NodeRSA from 'node-rsa';
|
|
|
9
9
|
import { Formatter } from '@stamhoofd/utility';
|
|
10
10
|
import { AuthenticatedStructures } from '../../../../helpers/AuthenticatedStructures.js';
|
|
11
11
|
import { Context } from '../../../../helpers/Context.js';
|
|
12
|
+
import { OrganizationDNSService } from '../../../../services/OrganizationDNSService.js';
|
|
12
13
|
|
|
13
14
|
type Params = Record<string, never>;
|
|
14
15
|
type Query = undefined;
|
|
@@ -169,7 +170,7 @@ export class SetOrganizationDomainEndpoint extends Endpoint<Params, Query, Body,
|
|
|
169
170
|
}));
|
|
170
171
|
} else {
|
|
171
172
|
if (oldMailDomain) {
|
|
172
|
-
|
|
173
|
+
OrganizationDNSService.deleteAWSMailIdenitity(organization, oldMailDomain).catch(console.error);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
if (organization.serverMeta.privateDKIMKey && organization.serverMeta.publicDKIMKey) {
|
|
@@ -188,7 +189,7 @@ export class SetOrganizationDomainEndpoint extends Endpoint<Params, Query, Body,
|
|
|
188
189
|
} else {
|
|
189
190
|
// Validate DNS-records if not empty
|
|
190
191
|
console.log('Validating domains');
|
|
191
|
-
await
|
|
192
|
+
await OrganizationDNSService.updateDNSRecords(organization);
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
console.log('Done.');
|
package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
13
13
|
import { PaymentMandateService } from '../../../../services/PaymentMandateService.js';
|
|
14
14
|
import { BalanceItemService } from '../../../../services/BalanceItemService.js';
|
|
15
15
|
import { QueueHandler } from '@stamhoofd/queues';
|
|
16
|
+
import { OrganizationAdminService } from '../../../../services/OrganizationAdminService.js';
|
|
16
17
|
|
|
17
18
|
type Params = Record<string, never>;
|
|
18
19
|
type Query = undefined;
|
|
@@ -121,11 +122,11 @@ export class ChargeReceivableBalancesEndpoint extends Endpoint<Params, Query, Bo
|
|
|
121
122
|
continue;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
customerUser = customerUser ?? (payingOrganization ? (await
|
|
125
|
+
customerUser = customerUser ?? (payingOrganization ? (await OrganizationAdminService.getFullAdmins(payingOrganization))[0] : null);
|
|
125
126
|
const customer = PaymentCustomer.create({
|
|
126
127
|
firstName: customerUser?.firstName,
|
|
127
128
|
lastName: customerUser?.lastName,
|
|
128
|
-
email: customerUser?.email ?? (payingOrganization ? (await
|
|
129
|
+
email: customerUser?.email ?? (payingOrganization ? (await OrganizationAdminService.getReplyEmails(payingOrganization))[0].email : null),
|
|
129
130
|
company: payingOrganization?.defaultCompanies[0],
|
|
130
131
|
});
|
|
131
132
|
|
|
@@ -15,6 +15,35 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
15
15
|
TestUtils.setEnvironment('userMode', 'platform');
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
test('A session that did not recently authenticate cannot create a key', async () => {
|
|
19
|
+
// An API key is not covered by two-factor authentication and does not expire, so a
|
|
20
|
+
// stolen session must not be enough to mint one: the user has to prove who they
|
|
21
|
+
// are again first.
|
|
22
|
+
const organization = await new OrganizationFactory({}).create();
|
|
23
|
+
const user = await new UserFactory({
|
|
24
|
+
permissions: Permissions.create({
|
|
25
|
+
level: PermissionLevel.Full,
|
|
26
|
+
}),
|
|
27
|
+
organization,
|
|
28
|
+
}).create();
|
|
29
|
+
|
|
30
|
+
// A token as produced by a refresh_token rotation (never authenticatedAt).
|
|
31
|
+
const token = await Token.createToken(user);
|
|
32
|
+
|
|
33
|
+
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
34
|
+
permissions: UserPermissions.create({
|
|
35
|
+
organizationPermissions: new Map([
|
|
36
|
+
[organization.id, Permissions.create({ level: PermissionLevel.Read })],
|
|
37
|
+
]),
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
createRequest.headers.authorization = 'Bearer ' + token.accessToken;
|
|
41
|
+
|
|
42
|
+
await expect(testServer.test(endpoint, createRequest)).rejects.toThrow(STExpect.simpleError({
|
|
43
|
+
code: 'require_fresh_auth',
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
|
|
18
47
|
test('Only a platform admin can set the rate limits of a key', async () => {
|
|
19
48
|
const organization = await new OrganizationFactory({}).create();
|
|
20
49
|
const user = await new UserFactory({
|
|
@@ -22,7 +51,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
22
51
|
level: PermissionLevel.Full,
|
|
23
52
|
}),
|
|
24
53
|
}).create();
|
|
25
|
-
const token = await Token.createToken(user);
|
|
54
|
+
const token = await Token.createToken(user, new Date());
|
|
26
55
|
|
|
27
56
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
28
57
|
permissions: UserPermissions.create({
|
|
@@ -49,7 +78,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
49
78
|
}),
|
|
50
79
|
organization,
|
|
51
80
|
}).create();
|
|
52
|
-
const token = await Token.createToken(user);
|
|
81
|
+
const token = await Token.createToken(user, new Date());
|
|
53
82
|
|
|
54
83
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
55
84
|
permissions: UserPermissions.create({
|
|
@@ -76,7 +105,7 @@ describe('Endpoint.CreateApiUserEndpoint', () => {
|
|
|
76
105
|
}),
|
|
77
106
|
organization,
|
|
78
107
|
}).create();
|
|
79
|
-
const token = await Token.createToken(user);
|
|
108
|
+
const token = await Token.createToken(user, new Date());
|
|
80
109
|
|
|
81
110
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
82
111
|
permissions: UserPermissions.create({
|
|
@@ -29,7 +29,10 @@ export class CreateApiUserEndpoint extends Endpoint<Params, Query, Body, Respons
|
|
|
29
29
|
|
|
30
30
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
31
31
|
const organization = await Context.setOrganizationScope();
|
|
32
|
-
|
|
32
|
+
// An API key is a long-lived credential that is not covered by two-factor
|
|
33
|
+
// authentication, so creating one requires a recent authentication: a stolen
|
|
34
|
+
// session should not be enough to mint a permanent way back in.
|
|
35
|
+
await Context.authenticateFresh();
|
|
33
36
|
|
|
34
37
|
// Fast throw first (more in depth checking for patches later)
|
|
35
38
|
if (!await Context.auth.canManageAdmins(organization.id)) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import type { Organization, User } from '@stamhoofd/models';
|
|
3
|
+
import { MFATOTP, OrganizationFactory, Token, UserFactory, WebauthnCredential } from '@stamhoofd/models';
|
|
4
|
+
import { OrganizationAdmins, PermissionLevel, Permissions } from '@stamhoofd/structures';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
|
|
7
|
+
import { MFATestHelper } from '../../../../../tests/helpers/MFATestHelper.js';
|
|
8
|
+
import { testServer } from '../../../../../tests/helpers/TestServer.js';
|
|
9
|
+
import { GetOrganizationAdminsEndpoint } from './GetOrganizationAdminsEndpoint.js';
|
|
10
|
+
|
|
11
|
+
describe('Endpoint.GetOrganizationAdmins', () => {
|
|
12
|
+
const endpoint = new GetOrganizationAdminsEndpoint();
|
|
13
|
+
|
|
14
|
+
async function createAdmin(organization: Organization): Promise<User> {
|
|
15
|
+
return await new UserFactory({
|
|
16
|
+
organization,
|
|
17
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
18
|
+
}).create();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function getAdmins(organization: Organization, user: User) {
|
|
22
|
+
const token = await Token.createToken(user, new Date());
|
|
23
|
+
const request = Request.buildJson('GET', '/organization/admins', organization.getApiHost());
|
|
24
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
25
|
+
|
|
26
|
+
const response = await testServer.test(endpoint, request);
|
|
27
|
+
expect(response.body).toBeInstanceOf(OrganizationAdmins);
|
|
28
|
+
return (response.body as OrganizationAdmins).users;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('The last activity of every admin is returned', async () => {
|
|
32
|
+
const organization = await new OrganizationFactory({}).create();
|
|
33
|
+
const user = await new UserFactory({
|
|
34
|
+
organization,
|
|
35
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
36
|
+
}).create();
|
|
37
|
+
|
|
38
|
+
const lastActiveAt = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
|
|
39
|
+
lastActiveAt.setMilliseconds(0);
|
|
40
|
+
|
|
41
|
+
const inactiveAdmin = await new UserFactory({
|
|
42
|
+
organization,
|
|
43
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
44
|
+
}).create();
|
|
45
|
+
inactiveAdmin.lastActiveAt = lastActiveAt;
|
|
46
|
+
await inactiveAdmin.save();
|
|
47
|
+
|
|
48
|
+
const token = await Token.createToken(user, new Date());
|
|
49
|
+
const request = Request.buildJson('GET', '/organization/admins', organization.getApiHost());
|
|
50
|
+
request.headers.authorization = 'Bearer ' + token.accessToken;
|
|
51
|
+
|
|
52
|
+
const response = await testServer.test(endpoint, request);
|
|
53
|
+
expect(response.body).toBeInstanceOf(OrganizationAdmins);
|
|
54
|
+
|
|
55
|
+
const users = (response.body as OrganizationAdmins).users;
|
|
56
|
+
expect(users.find(u => u.id === inactiveAdmin.id)?.lastActiveAt?.getTime()).toEqual(lastActiveAt.getTime());
|
|
57
|
+
|
|
58
|
+
// The user never signed in: the token was created directly.
|
|
59
|
+
expect(users.find(u => u.id === user.id)?.lastActiveAt).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('Whether an admin has two-factor authentication is returned', async () => {
|
|
63
|
+
const organization = await new OrganizationFactory({}).create();
|
|
64
|
+
const me = await createAdmin(organization);
|
|
65
|
+
|
|
66
|
+
const withTotp = await createAdmin(organization);
|
|
67
|
+
await MFATestHelper.addConfirmedTOTP(withTotp);
|
|
68
|
+
|
|
69
|
+
const withPasskey = await createAdmin(organization);
|
|
70
|
+
const credential = new WebauthnCredential();
|
|
71
|
+
credential.userId = withPasskey.id;
|
|
72
|
+
credential.credentialId = 'cred-' + crypto.randomBytes(16).toString('base64url');
|
|
73
|
+
credential.publicKey = crypto.randomBytes(32).toString('base64url');
|
|
74
|
+
credential.name = 'Test passkey';
|
|
75
|
+
await credential.save();
|
|
76
|
+
|
|
77
|
+
const withoutFactors = await createAdmin(organization);
|
|
78
|
+
|
|
79
|
+
const users = await getAdmins(organization, me);
|
|
80
|
+
|
|
81
|
+
expect(users.find(u => u.id === withTotp.id)?.hasTwoFactor).toBe(true);
|
|
82
|
+
expect(users.find(u => u.id === withPasskey.id)?.hasTwoFactor).toBe(true);
|
|
83
|
+
expect(users.find(u => u.id === withoutFactors.id)?.hasTwoFactor).toBe(false);
|
|
84
|
+
expect(users.find(u => u.id === me.id)?.hasTwoFactor).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('An authenticator that was never confirmed does not count as two-factor authentication', async () => {
|
|
88
|
+
const organization = await new OrganizationFactory({}).create();
|
|
89
|
+
const me = await createAdmin(organization);
|
|
90
|
+
|
|
91
|
+
const totp = new MFATOTP();
|
|
92
|
+
totp.userId = me.id;
|
|
93
|
+
totp.name = 'Pending authenticator';
|
|
94
|
+
totp.secret = 'encrypted-secret';
|
|
95
|
+
await totp.save();
|
|
96
|
+
|
|
97
|
+
const users = await getAdmins(organization, me);
|
|
98
|
+
expect(users.find(u => u.id === me.id)?.hasTwoFactor).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -4,6 +4,7 @@ import { User } from '@stamhoofd/models';
|
|
|
4
4
|
import { OrganizationAdmins } from '@stamhoofd/structures';
|
|
5
5
|
|
|
6
6
|
import { Context } from '../../../../helpers/Context.js';
|
|
7
|
+
import { TwoFactorHelper } from '../../../../helpers/TwoFactorHelper.js';
|
|
7
8
|
type Params = Record<string, never>;
|
|
8
9
|
type Query = undefined;
|
|
9
10
|
type Body = undefined;
|
|
@@ -34,10 +35,12 @@ export class GetOrganizationAdminsEndpoint extends Endpoint<Params, Query, Body,
|
|
|
34
35
|
|
|
35
36
|
// Get all admins
|
|
36
37
|
const admins = await User.getAdmins(organization.id);
|
|
38
|
+
const users = admins.map(user => user.getStructure());
|
|
39
|
+
await TwoFactorHelper.fillTwoFactorStatus(users);
|
|
37
40
|
|
|
38
41
|
return new Response(
|
|
39
42
|
OrganizationAdmins.create({
|
|
40
|
-
users
|
|
43
|
+
users,
|
|
41
44
|
}),
|
|
42
45
|
);
|
|
43
46
|
}
|
|
@@ -24,7 +24,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
24
24
|
level: PermissionLevel.Full,
|
|
25
25
|
}),
|
|
26
26
|
}).create();
|
|
27
|
-
const token = await Token.createToken(user);
|
|
27
|
+
const token = await Token.createToken(user, new Date());
|
|
28
28
|
|
|
29
29
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
30
30
|
permissions: UserPermissions.create({
|
|
@@ -60,7 +60,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
60
60
|
}),
|
|
61
61
|
organization,
|
|
62
62
|
}).create();
|
|
63
|
-
const token = await Token.createToken(user);
|
|
63
|
+
const token = await Token.createToken(user, new Date());
|
|
64
64
|
|
|
65
65
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
66
66
|
permissions: UserPermissions.create({
|
|
@@ -95,7 +95,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
95
95
|
}),
|
|
96
96
|
organization,
|
|
97
97
|
}).create();
|
|
98
|
-
const token = await Token.createToken(user);
|
|
98
|
+
const token = await Token.createToken(user, new Date());
|
|
99
99
|
|
|
100
100
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
101
101
|
permissions: UserPermissions.create({
|
|
@@ -143,7 +143,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
143
143
|
}),
|
|
144
144
|
organization,
|
|
145
145
|
}).create();
|
|
146
|
-
const token = await Token.createToken(user);
|
|
146
|
+
const token = await Token.createToken(user, new Date());
|
|
147
147
|
|
|
148
148
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
149
149
|
permissions: UserPermissions.create({
|
|
@@ -190,7 +190,7 @@ describe('Endpoint.PatchApiUserEndpoint', () => {
|
|
|
190
190
|
}),
|
|
191
191
|
organization,
|
|
192
192
|
}).create();
|
|
193
|
-
const token = await Token.createToken(user);
|
|
193
|
+
const token = await Token.createToken(user, new Date());
|
|
194
194
|
|
|
195
195
|
const createRequest = Request.buildJson('POST', '/api-keys', organization.getApiHost(), ApiUser.create({
|
|
196
196
|
permissions: UserPermissions.create({
|
|
@@ -3,7 +3,7 @@ import { isPatch } from '@simonbackx/simple-encoding';
|
|
|
3
3
|
import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
|
|
4
4
|
import { Endpoint, Response } from '@simonbackx/simple-endpoints';
|
|
5
5
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
-
import { Token, User } from '@stamhoofd/models';
|
|
6
|
+
import { Platform, Token, User } from '@stamhoofd/models';
|
|
7
7
|
import { ApiUser, PermissionLevel, UserMeta, UserPermissions } from '@stamhoofd/structures';
|
|
8
8
|
import { Context } from '../../../../helpers/Context.js';
|
|
9
9
|
|
|
@@ -58,10 +58,12 @@ export class PatchApiUserEndpoint extends Endpoint<Params, Query, Body, Response
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
if (request.body.permissions) {
|
|
61
|
+
const platform = await Platform.getSharedStruct();
|
|
62
|
+
|
|
61
63
|
if (organization) {
|
|
62
64
|
editUser.permissions = UserPermissions.limitedPatch(editUser.permissions, request.body.permissions, organization.id);
|
|
63
65
|
|
|
64
|
-
if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization)?.hasFullAccess())) {
|
|
66
|
+
if (editUser.id === user.id && (!editUser.permissions || !editUser.permissions.forOrganization(organization, platform, { inheritFromPlatform: false })?.hasFullAccess())) {
|
|
65
67
|
throw new SimpleError({
|
|
66
68
|
code: 'permission_denied',
|
|
67
69
|
message: 'Je kan jezelf niet verwijderen als hoofdbeheerder',
|
|
@@ -74,7 +76,7 @@ export class PatchApiUserEndpoint extends Endpoint<Params, Query, Body, Response
|
|
|
74
76
|
editUser.permissions = request.body.permissions.isPut() ? request.body.permissions : null;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
if (editUser.id === user.id && !editUser.permissions?.platform?.hasFullAccess()) {
|
|
79
|
+
if (editUser.id === user.id && !editUser.permissions?.forPlatform(platform)?.hasFullAccess()) {
|
|
78
80
|
throw new SimpleError({
|
|
79
81
|
code: 'permission_denied',
|
|
80
82
|
message: 'Je kan jezelf niet verwijderen als hoofdbeheerder',
|