@ftisindia/create-app 0.1.3 → 0.1.4
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 +1 -1
- package/template/README.md +1 -1
- package/template/_package.json +0 -2
- package/template/docs/API_REFERENCE.md +13 -0
- package/template/docs/OAUTH.md +7 -3
- package/template/scripts/gen-module.mjs +2 -0
- package/template/src/app.module.ts +16 -22
- package/template/src/common/dto/error-response.dto.ts +3 -3
- package/template/src/common/dto/membership-response.dto.ts +26 -14
- package/template/src/common/dto/mutation-response.dto.ts +1 -1
- package/template/src/common/dto/pagination-query.dto.ts +37 -0
- package/template/src/common/dto/role-summary.dto.ts +5 -5
- package/template/src/common/dto/user-summary.dto.ts +6 -6
- package/template/src/common/filters/http-exception.filter.ts +9 -19
- package/template/src/common/swagger/api-error-responses.ts +12 -12
- package/template/src/config/app.config.ts +3 -3
- package/template/src/config/auth.config.ts +3 -3
- package/template/src/config/database.config.ts +3 -3
- package/template/src/config/env.validation.ts +58 -40
- package/template/src/config/index.ts +5 -5
- package/template/src/config/rbac.config.ts +3 -3
- package/template/src/database/prisma/prisma-transaction.ts +1 -1
- package/template/src/database/prisma/prisma.module.ts +2 -2
- package/template/src/database/prisma/prisma.service.ts +3 -6
- package/template/src/main.ts +11 -11
- package/template/src/modules/access-control/access-control.module.ts +9 -9
- package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
- package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
- package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
- package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
- package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
- package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
- package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
- package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
- package/template/src/modules/access-control/types/permission-key.ts +19 -19
- package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
- package/template/src/modules/audit/application/services/audit.service.ts +7 -7
- package/template/src/modules/audit/audit.module.ts +4 -4
- package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
- package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
- package/template/src/modules/auth/application/services/auth.service.ts +147 -110
- package/template/src/modules/auth/application/services/password.service.ts +2 -2
- package/template/src/modules/auth/application/services/token.service.ts +20 -21
- package/template/src/modules/auth/auth.module.ts +20 -47
- package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
- package/template/src/modules/auth/dto/login.dto.ts +4 -4
- package/template/src/modules/auth/dto/logout.dto.ts +1 -1
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
- package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
- package/template/src/modules/auth/dto/signup.dto.ts +5 -11
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
- package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
- package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
- package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
- package/template/src/modules/health/dto/health-response.dto.ts +5 -5
- package/template/src/modules/health/health.module.ts +2 -2
- package/template/src/modules/health/presentation/health.controller.ts +13 -13
- package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
- package/template/src/modules/invitations/invitations.module.ts +5 -5
- package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
- package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
- package/template/src/modules/memberships/memberships.module.ts +4 -4
- package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
- package/template/src/modules/organisations/application/services/organisations.service.ts +21 -23
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +14 -14
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
- package/template/src/modules/organisations/organisations.module.ts +5 -5
- package/template/src/modules/organisations/presentation/organisations.controller.ts +14 -23
- package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
- package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
- package/template/src/modules/request-context/request-context.module.ts +7 -7
- package/template/src/modules/request-context/types/request-context.ts +2 -2
- package/template/src/modules/sample/application/services/sample.service.ts +10 -8
- package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
- package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
- package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
- package/template/src/modules/sample/sample.module.ts +4 -4
- package/template/src/modules/settings/application/services/settings.service.ts +15 -27
- package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
- package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
- package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
- package/template/src/modules/settings/settings.module.ts +5 -5
- package/template/src/modules/settings/types/setting-definitions.ts +49 -33
- package/template/test/auth-refresh.spec.ts +90 -0
- package/template/test/role-permission-policy.spec.ts +94 -0
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
Injectable,
|
|
6
6
|
NotFoundException,
|
|
7
7
|
UnauthorizedException,
|
|
8
|
-
} from
|
|
8
|
+
} from '@nestjs/common';
|
|
9
9
|
import {
|
|
10
10
|
AuthProvider,
|
|
11
11
|
InvitationStatus,
|
|
@@ -13,18 +13,24 @@ import {
|
|
|
13
13
|
MembershipStatus,
|
|
14
14
|
Prisma,
|
|
15
15
|
User,
|
|
16
|
-
} from
|
|
17
|
-
import { isEmail } from
|
|
18
|
-
import { createHash, randomBytes } from
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
16
|
+
} from '@prisma/client';
|
|
17
|
+
import { isEmail } from 'class-validator';
|
|
18
|
+
import { createHash, randomBytes } from 'crypto';
|
|
19
|
+
import {
|
|
20
|
+
PaginationQueryDto,
|
|
21
|
+
resolvePageLimit,
|
|
22
|
+
toPage,
|
|
23
|
+
} from '../../../../common/dto/pagination-query.dto';
|
|
24
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
25
|
+
import { assertRoleWithinActorPermissions } from '../../../access-control/application/role-permission-policy';
|
|
26
|
+
import { RbacCacheService } from '../../../access-control/application/services/rbac-cache.service';
|
|
27
|
+
import { PasswordService } from '../../../auth/application/services/password.service';
|
|
28
|
+
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
29
|
+
import { MembershipsService } from '../../../memberships/application/services/memberships.service';
|
|
30
|
+
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
31
|
+
import { AcceptInvitationDto } from '../../dto/accept-invitation.dto';
|
|
32
|
+
import { CreateInvitationDto } from '../../dto/create-invitation.dto';
|
|
33
|
+
import { InvitationTokenDto } from '../../dto/invitation-token.dto';
|
|
28
34
|
|
|
29
35
|
const DEFAULT_INVITATION_EXPIRY_DAYS = 7;
|
|
30
36
|
const mobileTargetPattern = /^\+?[1-9]\d{6,14}$/;
|
|
@@ -90,41 +96,22 @@ export class InvitationsService {
|
|
|
90
96
|
private readonly requestContext: RequestContextService,
|
|
91
97
|
) {}
|
|
92
98
|
|
|
93
|
-
async createInvitation(
|
|
94
|
-
currentUser: AuthenticatedUser,
|
|
95
|
-
orgId: string,
|
|
96
|
-
dto: CreateInvitationDto,
|
|
97
|
-
) {
|
|
99
|
+
async createInvitation(currentUser: AuthenticatedUser, orgId: string, dto: CreateInvitationDto) {
|
|
98
100
|
this.requestContext.assertOrgScope(orgId);
|
|
99
101
|
|
|
100
|
-
const normalizedTargetValue = normalizeTarget(
|
|
101
|
-
dto.targetType,
|
|
102
|
-
dto.targetValue,
|
|
103
|
-
);
|
|
102
|
+
const normalizedTargetValue = normalizeTarget(dto.targetType, dto.targetValue);
|
|
104
103
|
const targetValue = dto.targetValue.trim();
|
|
105
104
|
const inviteToken = createInvitationToken();
|
|
106
105
|
const tokenHash = hashInvitationToken(inviteToken);
|
|
107
|
-
const expiresAt = addDays(
|
|
108
|
-
new Date(),
|
|
109
|
-
dto.expiresInDays ?? DEFAULT_INVITATION_EXPIRY_DAYS,
|
|
110
|
-
);
|
|
106
|
+
const expiresAt = addDays(new Date(), dto.expiresInDays ?? DEFAULT_INVITATION_EXPIRY_DAYS);
|
|
111
107
|
|
|
112
108
|
let invitation: InvitationDetail;
|
|
113
109
|
try {
|
|
114
110
|
invitation = await this.prisma.$transaction(async (tx) => {
|
|
115
|
-
await this.membershipsService.assertActiveMembership(
|
|
116
|
-
currentUser.id,
|
|
117
|
-
orgId,
|
|
118
|
-
tx,
|
|
119
|
-
);
|
|
111
|
+
await this.membershipsService.assertActiveMembership(currentUser.id, orgId, tx);
|
|
120
112
|
await this.lockOrganisation(tx, orgId);
|
|
121
|
-
await this.
|
|
122
|
-
await this.assertNoPendingInvitation(
|
|
123
|
-
tx,
|
|
124
|
-
orgId,
|
|
125
|
-
dto.targetType,
|
|
126
|
-
normalizedTargetValue,
|
|
127
|
-
);
|
|
113
|
+
await this.assertRoleWithinInviterPermissions(tx, currentUser, orgId, dto.roleId);
|
|
114
|
+
await this.assertNoPendingInvitation(tx, orgId, dto.targetType, normalizedTargetValue);
|
|
128
115
|
await this.assertTargetIsNotExistingMember(
|
|
129
116
|
tx,
|
|
130
117
|
orgId,
|
|
@@ -149,7 +136,7 @@ export class InvitationsService {
|
|
|
149
136
|
await this.writeAudit(tx, {
|
|
150
137
|
orgId,
|
|
151
138
|
actorUserId: currentUser.id,
|
|
152
|
-
action:
|
|
139
|
+
action: 'invitation.create',
|
|
153
140
|
targetId: created.id,
|
|
154
141
|
metadata: {
|
|
155
142
|
targetType: created.targetType,
|
|
@@ -163,9 +150,7 @@ export class InvitationsService {
|
|
|
163
150
|
});
|
|
164
151
|
} catch (error) {
|
|
165
152
|
if (isPrismaUniqueError(error)) {
|
|
166
|
-
throw new ConflictException(
|
|
167
|
-
"A pending invitation already exists for this target.",
|
|
168
|
-
);
|
|
153
|
+
throw new ConflictException('A pending invitation already exists for this target.');
|
|
169
154
|
}
|
|
170
155
|
|
|
171
156
|
throw error;
|
|
@@ -177,38 +162,34 @@ export class InvitationsService {
|
|
|
177
162
|
};
|
|
178
163
|
}
|
|
179
164
|
|
|
180
|
-
async listInvitations(currentUser: AuthenticatedUser, orgId: string) {
|
|
165
|
+
async listInvitations(currentUser: AuthenticatedUser, orgId: string, query: PaginationQueryDto) {
|
|
181
166
|
this.requestContext.assertOrgScope(orgId);
|
|
182
167
|
await this.membershipsService.assertActiveMembership(currentUser.id, orgId);
|
|
168
|
+
const limit = resolvePageLimit(query.limit);
|
|
183
169
|
|
|
184
170
|
const invitations = await this.prisma.invitation.findMany({
|
|
185
171
|
where: { orgId },
|
|
186
172
|
include: invitationDetailInclude,
|
|
187
|
-
orderBy: { createdAt:
|
|
173
|
+
orderBy: { createdAt: 'desc' },
|
|
174
|
+
take: limit + 1,
|
|
175
|
+
...(query.cursor
|
|
176
|
+
? {
|
|
177
|
+
cursor: { id: query.cursor },
|
|
178
|
+
skip: 1,
|
|
179
|
+
}
|
|
180
|
+
: {}),
|
|
188
181
|
});
|
|
189
182
|
|
|
190
|
-
return invitations.map(serializeInvitation);
|
|
183
|
+
return toPage(invitations.map(serializeInvitation), limit);
|
|
191
184
|
}
|
|
192
185
|
|
|
193
|
-
async revokeInvitation(
|
|
194
|
-
currentUser: AuthenticatedUser,
|
|
195
|
-
orgId: string,
|
|
196
|
-
invitationId: string,
|
|
197
|
-
) {
|
|
186
|
+
async revokeInvitation(currentUser: AuthenticatedUser, orgId: string, invitationId: string) {
|
|
198
187
|
this.requestContext.assertOrgScope(orgId);
|
|
199
188
|
|
|
200
189
|
return this.prisma.$transaction(async (tx) => {
|
|
201
|
-
await this.membershipsService.assertActiveMembership(
|
|
202
|
-
currentUser.id,
|
|
203
|
-
orgId,
|
|
204
|
-
tx,
|
|
205
|
-
);
|
|
190
|
+
await this.membershipsService.assertActiveMembership(currentUser.id, orgId, tx);
|
|
206
191
|
|
|
207
|
-
const invitation = await this.findInvitationInOrgForUpdate(
|
|
208
|
-
tx,
|
|
209
|
-
orgId,
|
|
210
|
-
invitationId,
|
|
211
|
-
);
|
|
192
|
+
const invitation = await this.findInvitationInOrgForUpdate(tx, orgId, invitationId);
|
|
212
193
|
this.assertPendingInvitation(invitation);
|
|
213
194
|
this.assertInvitationNotExpiredInTransaction(invitation);
|
|
214
195
|
|
|
@@ -236,7 +217,7 @@ export class InvitationsService {
|
|
|
236
217
|
await this.writeAudit(tx, {
|
|
237
218
|
orgId,
|
|
238
219
|
actorUserId: currentUser.id,
|
|
239
|
-
action:
|
|
220
|
+
action: 'invitation.revoke',
|
|
240
221
|
targetId: invitation.id,
|
|
241
222
|
metadata: {
|
|
242
223
|
targetType: invitation.targetType,
|
|
@@ -248,30 +229,18 @@ export class InvitationsService {
|
|
|
248
229
|
});
|
|
249
230
|
}
|
|
250
231
|
|
|
251
|
-
async resendInvitation(
|
|
252
|
-
currentUser: AuthenticatedUser,
|
|
253
|
-
orgId: string,
|
|
254
|
-
invitationId: string,
|
|
255
|
-
) {
|
|
232
|
+
async resendInvitation(currentUser: AuthenticatedUser, orgId: string, invitationId: string) {
|
|
256
233
|
this.requestContext.assertOrgScope(orgId);
|
|
257
234
|
|
|
258
235
|
const inviteToken = createInvitationToken();
|
|
259
236
|
const tokenHash = hashInvitationToken(inviteToken);
|
|
260
237
|
|
|
261
238
|
const invitation = await this.prisma.$transaction(async (tx) => {
|
|
262
|
-
await this.membershipsService.assertActiveMembership(
|
|
263
|
-
currentUser.id,
|
|
264
|
-
orgId,
|
|
265
|
-
tx,
|
|
266
|
-
);
|
|
239
|
+
await this.membershipsService.assertActiveMembership(currentUser.id, orgId, tx);
|
|
267
240
|
|
|
268
|
-
const existing = await this.findInvitationInOrgForUpdate(
|
|
269
|
-
tx,
|
|
270
|
-
orgId,
|
|
271
|
-
invitationId,
|
|
272
|
-
);
|
|
241
|
+
const existing = await this.findInvitationInOrgForUpdate(tx, orgId, invitationId);
|
|
273
242
|
this.assertResendableInvitation(existing);
|
|
274
|
-
await this.
|
|
243
|
+
await this.assertRoleWithinInviterPermissions(tx, currentUser, orgId, existing.roleId);
|
|
275
244
|
await this.assertTargetIsNotExistingMember(
|
|
276
245
|
tx,
|
|
277
246
|
orgId,
|
|
@@ -305,7 +274,7 @@ export class InvitationsService {
|
|
|
305
274
|
await this.writeAudit(tx, {
|
|
306
275
|
orgId,
|
|
307
276
|
actorUserId: currentUser.id,
|
|
308
|
-
action:
|
|
277
|
+
action: 'invitation.resend',
|
|
309
278
|
targetId: existing.id,
|
|
310
279
|
metadata: {
|
|
311
280
|
targetType: existing.targetType,
|
|
@@ -329,24 +298,13 @@ export class InvitationsService {
|
|
|
329
298
|
await this.assertInvitationNotExpired(invitation);
|
|
330
299
|
|
|
331
300
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
332
|
-
const pendingInvitation = await this.findInvitationByTokenForUpdate(
|
|
333
|
-
tx,
|
|
334
|
-
tokenHash,
|
|
335
|
-
);
|
|
301
|
+
const pendingInvitation = await this.findInvitationByTokenForUpdate(tx, tokenHash);
|
|
336
302
|
this.assertPendingInvitation(pendingInvitation);
|
|
337
303
|
this.assertInvitationNotExpiredInTransaction(pendingInvitation);
|
|
338
|
-
await this.assertRoleBelongsToOrg(
|
|
339
|
-
tx,
|
|
340
|
-
pendingInvitation.orgId,
|
|
341
|
-
pendingInvitation.roleId,
|
|
342
|
-
);
|
|
304
|
+
await this.assertRoleBelongsToOrg(tx, pendingInvitation.orgId, pendingInvitation.roleId);
|
|
343
305
|
|
|
344
306
|
const user = await this.resolveTargetUser(tx, pendingInvitation, dto);
|
|
345
|
-
await this.assertMembershipCanBeCreated(
|
|
346
|
-
tx,
|
|
347
|
-
pendingInvitation.orgId,
|
|
348
|
-
user.id,
|
|
349
|
-
);
|
|
307
|
+
await this.assertMembershipCanBeCreated(tx, pendingInvitation.orgId, user.id);
|
|
350
308
|
|
|
351
309
|
const membership = await tx.membership.create({
|
|
352
310
|
data: {
|
|
@@ -385,7 +343,7 @@ export class InvitationsService {
|
|
|
385
343
|
await this.writeAudit(tx, {
|
|
386
344
|
orgId: pendingInvitation.orgId,
|
|
387
345
|
actorUserId: user.id,
|
|
388
|
-
action:
|
|
346
|
+
action: 'invitation.accept',
|
|
389
347
|
targetId: pendingInvitation.id,
|
|
390
348
|
metadata: {
|
|
391
349
|
targetType: pendingInvitation.targetType,
|
|
@@ -397,8 +355,8 @@ export class InvitationsService {
|
|
|
397
355
|
await this.writeAudit(tx, {
|
|
398
356
|
orgId: pendingInvitation.orgId,
|
|
399
357
|
actorUserId: user.id,
|
|
400
|
-
action:
|
|
401
|
-
targetType:
|
|
358
|
+
action: 'membership.created',
|
|
359
|
+
targetType: 'Membership',
|
|
402
360
|
targetId: membership.id,
|
|
403
361
|
metadata: {
|
|
404
362
|
invitedByUserId: pendingInvitation.invitedByUserId,
|
|
@@ -419,10 +377,7 @@ export class InvitationsService {
|
|
|
419
377
|
};
|
|
420
378
|
});
|
|
421
379
|
|
|
422
|
-
this.rbacCache.invalidate(
|
|
423
|
-
result.rbacInvalidation.userId,
|
|
424
|
-
result.rbacInvalidation.orgId,
|
|
425
|
-
);
|
|
380
|
+
this.rbacCache.invalidate(result.rbacInvalidation.userId, result.rbacInvalidation.orgId);
|
|
426
381
|
|
|
427
382
|
const { rbacInvalidation: _rbacInvalidation, ...response } = result;
|
|
428
383
|
return response;
|
|
@@ -434,10 +389,7 @@ export class InvitationsService {
|
|
|
434
389
|
await this.assertInvitationNotExpired(invitation);
|
|
435
390
|
|
|
436
391
|
return this.prisma.$transaction(async (tx) => {
|
|
437
|
-
const pendingInvitation = await this.findInvitationByTokenForUpdate(
|
|
438
|
-
tx,
|
|
439
|
-
tokenHash,
|
|
440
|
-
);
|
|
392
|
+
const pendingInvitation = await this.findInvitationByTokenForUpdate(tx, tokenHash);
|
|
441
393
|
this.assertPendingInvitation(pendingInvitation);
|
|
442
394
|
this.assertInvitationNotExpiredInTransaction(pendingInvitation);
|
|
443
395
|
|
|
@@ -463,7 +415,7 @@ export class InvitationsService {
|
|
|
463
415
|
|
|
464
416
|
await this.writeAudit(tx, {
|
|
465
417
|
orgId: pendingInvitation.orgId,
|
|
466
|
-
action:
|
|
418
|
+
action: 'invitation.decline',
|
|
467
419
|
targetId: pendingInvitation.id,
|
|
468
420
|
metadata: {
|
|
469
421
|
targetType: pendingInvitation.targetType,
|
|
@@ -489,10 +441,7 @@ export class InvitationsService {
|
|
|
489
441
|
return invitation;
|
|
490
442
|
}
|
|
491
443
|
|
|
492
|
-
private async findInvitationByTokenForUpdate(
|
|
493
|
-
tx: Prisma.TransactionClient,
|
|
494
|
-
tokenHash: string,
|
|
495
|
-
) {
|
|
444
|
+
private async findInvitationByTokenForUpdate(tx: Prisma.TransactionClient, tokenHash: string) {
|
|
496
445
|
const rows = await tx.$queryRaw<Array<{ id: string }>>`
|
|
497
446
|
SELECT "id"
|
|
498
447
|
FROM "Invitation"
|
|
@@ -516,11 +465,7 @@ export class InvitationsService {
|
|
|
516
465
|
return invitation;
|
|
517
466
|
}
|
|
518
467
|
|
|
519
|
-
private async findInvitationInOrg(
|
|
520
|
-
client: PrismaClient,
|
|
521
|
-
orgId: string,
|
|
522
|
-
invitationId: string,
|
|
523
|
-
) {
|
|
468
|
+
private async findInvitationInOrg(client: PrismaClient, orgId: string, invitationId: string) {
|
|
524
469
|
const invitation = await client.invitation.findFirst({
|
|
525
470
|
where: {
|
|
526
471
|
id: invitationId,
|
|
@@ -530,9 +475,7 @@ export class InvitationsService {
|
|
|
530
475
|
});
|
|
531
476
|
|
|
532
477
|
if (!invitation) {
|
|
533
|
-
throw new NotFoundException(
|
|
534
|
-
"Invitation was not found in this organisation.",
|
|
535
|
-
);
|
|
478
|
+
throw new NotFoundException('Invitation was not found in this organisation.');
|
|
536
479
|
}
|
|
537
480
|
|
|
538
481
|
return invitation;
|
|
@@ -552,9 +495,7 @@ export class InvitationsService {
|
|
|
552
495
|
`;
|
|
553
496
|
|
|
554
497
|
if (rows.length === 0) {
|
|
555
|
-
throw new NotFoundException(
|
|
556
|
-
"Invitation was not found in this organisation.",
|
|
557
|
-
);
|
|
498
|
+
throw new NotFoundException('Invitation was not found in this organisation.');
|
|
558
499
|
}
|
|
559
500
|
|
|
560
501
|
return tx.invitation.findUniqueOrThrow({
|
|
@@ -572,8 +513,29 @@ export class InvitationsService {
|
|
|
572
513
|
`;
|
|
573
514
|
}
|
|
574
515
|
|
|
575
|
-
private async assertRoleBelongsToOrg(
|
|
516
|
+
private async assertRoleBelongsToOrg(client: PrismaClient, orgId: string, roleId: string) {
|
|
517
|
+
const role = await client.role.findUnique({
|
|
518
|
+
where: {
|
|
519
|
+
id_orgId: {
|
|
520
|
+
id: roleId,
|
|
521
|
+
orgId,
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
select: { id: true },
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if (!role) {
|
|
528
|
+
throw new NotFoundException('Role was not found in this organisation.');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Used on the invite-create and resend paths (where there is an authenticated
|
|
533
|
+
// inviter): the role must exist in the org AND its permissions must be within
|
|
534
|
+
// the inviter's own, so a member cannot invite someone into a more privileged
|
|
535
|
+
// role than they hold. The public accept path keeps assertRoleBelongsToOrg.
|
|
536
|
+
private async assertRoleWithinInviterPermissions(
|
|
576
537
|
client: PrismaClient,
|
|
538
|
+
currentUser: AuthenticatedUser,
|
|
577
539
|
orgId: string,
|
|
578
540
|
roleId: string,
|
|
579
541
|
) {
|
|
@@ -584,12 +546,23 @@ export class InvitationsService {
|
|
|
584
546
|
orgId,
|
|
585
547
|
},
|
|
586
548
|
},
|
|
587
|
-
select: {
|
|
549
|
+
select: {
|
|
550
|
+
permissions: {
|
|
551
|
+
select: { permission: { select: { key: true } } },
|
|
552
|
+
},
|
|
553
|
+
},
|
|
588
554
|
});
|
|
589
555
|
|
|
590
556
|
if (!role) {
|
|
591
|
-
throw new NotFoundException(
|
|
557
|
+
throw new NotFoundException('Role was not found in this organisation.');
|
|
592
558
|
}
|
|
559
|
+
|
|
560
|
+
const actorContext = await this.rbacCache.getContext(currentUser.id, orgId);
|
|
561
|
+
assertRoleWithinActorPermissions({
|
|
562
|
+
actorIsOwner: actorContext.isOwner,
|
|
563
|
+
actorPermissionKeys: actorContext.permissionKeys,
|
|
564
|
+
rolePermissionKeys: role.permissions.map((rolePermission) => rolePermission.permission.key),
|
|
565
|
+
});
|
|
593
566
|
}
|
|
594
567
|
|
|
595
568
|
private async assertNoPendingInvitation(
|
|
@@ -609,34 +582,26 @@ export class InvitationsService {
|
|
|
609
582
|
});
|
|
610
583
|
|
|
611
584
|
if (existing) {
|
|
612
|
-
throw new ConflictException(
|
|
613
|
-
"A pending invitation already exists for this target.",
|
|
614
|
-
);
|
|
585
|
+
throw new ConflictException('A pending invitation already exists for this target.');
|
|
615
586
|
}
|
|
616
587
|
}
|
|
617
588
|
|
|
618
|
-
private assertPendingInvitation(
|
|
619
|
-
invitation: Pick<InvitationDetail, "status">,
|
|
620
|
-
) {
|
|
589
|
+
private assertPendingInvitation(invitation: Pick<InvitationDetail, 'status'>) {
|
|
621
590
|
if (invitation.status === InvitationStatus.EXPIRED) {
|
|
622
|
-
throw new GoneException(
|
|
591
|
+
throw new GoneException('Invitation has expired.');
|
|
623
592
|
}
|
|
624
593
|
|
|
625
594
|
if (invitation.status !== InvitationStatus.PENDING) {
|
|
626
|
-
throw new ConflictException(
|
|
595
|
+
throw new ConflictException('Invitation is no longer pending.');
|
|
627
596
|
}
|
|
628
597
|
}
|
|
629
598
|
|
|
630
|
-
private assertResendableInvitation(
|
|
631
|
-
invitation: Pick<InvitationDetail, "status">,
|
|
632
|
-
) {
|
|
599
|
+
private assertResendableInvitation(invitation: Pick<InvitationDetail, 'status'>) {
|
|
633
600
|
if (
|
|
634
601
|
invitation.status !== InvitationStatus.PENDING &&
|
|
635
602
|
invitation.status !== InvitationStatus.EXPIRED
|
|
636
603
|
) {
|
|
637
|
-
throw new ConflictException(
|
|
638
|
-
"Invitation cannot be resent after it has been completed.",
|
|
639
|
-
);
|
|
604
|
+
throw new ConflictException('Invitation cannot be resent after it has been completed.');
|
|
640
605
|
}
|
|
641
606
|
}
|
|
642
607
|
|
|
@@ -646,21 +611,19 @@ export class InvitationsService {
|
|
|
646
611
|
}
|
|
647
612
|
|
|
648
613
|
await this.markInvitationExpired(invitation);
|
|
649
|
-
throw new GoneException(
|
|
614
|
+
throw new GoneException('Invitation has expired.');
|
|
650
615
|
}
|
|
651
616
|
|
|
652
|
-
private assertInvitationNotExpiredInTransaction(
|
|
653
|
-
invitation: InvitationDetail,
|
|
654
|
-
) {
|
|
617
|
+
private assertInvitationNotExpiredInTransaction(invitation: InvitationDetail) {
|
|
655
618
|
if (invitation.status === InvitationStatus.EXPIRED) {
|
|
656
|
-
throw new GoneException(
|
|
619
|
+
throw new GoneException('Invitation has expired.');
|
|
657
620
|
}
|
|
658
621
|
|
|
659
622
|
if (invitation.expiresAt > new Date()) {
|
|
660
623
|
return;
|
|
661
624
|
}
|
|
662
625
|
|
|
663
|
-
throw new GoneException(
|
|
626
|
+
throw new GoneException('Invitation has expired.');
|
|
664
627
|
}
|
|
665
628
|
|
|
666
629
|
private async markInvitationExpired(invitation: InvitationDetail) {
|
|
@@ -668,9 +631,7 @@ export class InvitationsService {
|
|
|
668
631
|
return;
|
|
669
632
|
}
|
|
670
633
|
|
|
671
|
-
await this.prisma.$transaction((tx) =>
|
|
672
|
-
this.markInvitationExpiredInTransaction(tx, invitation),
|
|
673
|
-
);
|
|
634
|
+
await this.prisma.$transaction((tx) => this.markInvitationExpiredInTransaction(tx, invitation));
|
|
674
635
|
}
|
|
675
636
|
|
|
676
637
|
private async markInvitationExpiredInTransaction(
|
|
@@ -694,7 +655,7 @@ export class InvitationsService {
|
|
|
694
655
|
|
|
695
656
|
await this.writeAudit(tx, {
|
|
696
657
|
orgId: invitation.orgId,
|
|
697
|
-
action:
|
|
658
|
+
action: 'invitation.expired',
|
|
698
659
|
targetId: invitation.id,
|
|
699
660
|
metadata: {
|
|
700
661
|
targetType: invitation.targetType,
|
|
@@ -726,7 +687,7 @@ export class InvitationsService {
|
|
|
726
687
|
|
|
727
688
|
if (existing) {
|
|
728
689
|
if (!existing.isActive) {
|
|
729
|
-
throw new ConflictException(
|
|
690
|
+
throw new ConflictException('The invited user is inactive.');
|
|
730
691
|
}
|
|
731
692
|
|
|
732
693
|
return existing;
|
|
@@ -734,7 +695,7 @@ export class InvitationsService {
|
|
|
734
695
|
|
|
735
696
|
if (!dto.password) {
|
|
736
697
|
throw new BadRequestException(
|
|
737
|
-
|
|
698
|
+
'Password is required when accepting an email invitation as a new user.',
|
|
738
699
|
);
|
|
739
700
|
}
|
|
740
701
|
|
|
@@ -770,7 +731,7 @@ export class InvitationsService {
|
|
|
770
731
|
|
|
771
732
|
if (existing) {
|
|
772
733
|
if (!existing.isActive) {
|
|
773
|
-
throw new ConflictException(
|
|
734
|
+
throw new ConflictException('The invited user is inactive.');
|
|
774
735
|
}
|
|
775
736
|
|
|
776
737
|
return existing;
|
|
@@ -818,14 +779,10 @@ export class InvitationsService {
|
|
|
818
779
|
}
|
|
819
780
|
|
|
820
781
|
if (existing.status === MembershipStatus.REVOKED) {
|
|
821
|
-
throw new ConflictException(
|
|
822
|
-
"Revoked memberships cannot be reused through an invitation.",
|
|
823
|
-
);
|
|
782
|
+
throw new ConflictException('Revoked memberships cannot be reused through an invitation.');
|
|
824
783
|
}
|
|
825
784
|
|
|
826
|
-
throw new ConflictException(
|
|
827
|
-
"User already has a membership in this organisation.",
|
|
828
|
-
);
|
|
785
|
+
throw new ConflictException('User already has a membership in this organisation.');
|
|
829
786
|
}
|
|
830
787
|
|
|
831
788
|
private async assertTargetIsNotExistingMember(
|
|
@@ -865,13 +822,11 @@ export class InvitationsService {
|
|
|
865
822
|
|
|
866
823
|
if (membership.status === MembershipStatus.REVOKED) {
|
|
867
824
|
throw new ConflictException(
|
|
868
|
-
|
|
825
|
+
'A revoked membership already exists for this invitation target.',
|
|
869
826
|
);
|
|
870
827
|
}
|
|
871
828
|
|
|
872
|
-
throw new ConflictException(
|
|
873
|
-
"Invitation target already has a membership in this organisation.",
|
|
874
|
-
);
|
|
829
|
+
throw new ConflictException('Invitation target already has a membership in this organisation.');
|
|
875
830
|
}
|
|
876
831
|
|
|
877
832
|
private async writeAudit(
|
|
@@ -890,24 +845,25 @@ export class InvitationsService {
|
|
|
890
845
|
orgId: data.orgId,
|
|
891
846
|
actorUserId: data.actorUserId,
|
|
892
847
|
action: data.action,
|
|
893
|
-
targetType: data.targetType ??
|
|
848
|
+
targetType: data.targetType ?? 'Invitation',
|
|
894
849
|
targetId: data.targetId,
|
|
895
850
|
metadata: data.metadata,
|
|
851
|
+
ipAddress: this.requestContext.getIpAddress(),
|
|
852
|
+
userAgent: this.requestContext.getUserAgent(),
|
|
896
853
|
},
|
|
897
854
|
});
|
|
898
855
|
}
|
|
899
856
|
}
|
|
900
857
|
|
|
901
858
|
function createInvitationToken() {
|
|
902
|
-
return randomBytes(32).toString(
|
|
859
|
+
return randomBytes(32).toString('base64url');
|
|
903
860
|
}
|
|
904
861
|
|
|
905
862
|
function hashInvitationToken(token: string) {
|
|
906
|
-
return createHash(
|
|
863
|
+
return createHash('sha256').update(token).digest('hex');
|
|
907
864
|
}
|
|
908
865
|
|
|
909
|
-
const deadTokenHash = () =>
|
|
910
|
-
hashInvitationToken(randomBytes(32).toString("base64url"));
|
|
866
|
+
const deadTokenHash = () => hashInvitationToken(randomBytes(32).toString('base64url'));
|
|
911
867
|
|
|
912
868
|
function addDays(date: Date, days: number) {
|
|
913
869
|
const next = new Date(date);
|
|
@@ -919,18 +875,16 @@ function normalizeTarget(type: InvitationTargetType, value: string) {
|
|
|
919
875
|
if (type === InvitationTargetType.EMAIL) {
|
|
920
876
|
const email = value.trim().toLowerCase();
|
|
921
877
|
if (!isEmail(email)) {
|
|
922
|
-
throw new BadRequestException(
|
|
878
|
+
throw new BadRequestException('Invitation email must be valid.');
|
|
923
879
|
}
|
|
924
880
|
return email;
|
|
925
881
|
}
|
|
926
882
|
|
|
927
|
-
const mobile = value.trim().replace(/[\s()-]/g,
|
|
928
|
-
const normalized = mobile.startsWith(
|
|
883
|
+
const mobile = value.trim().replace(/[\s()-]/g, '');
|
|
884
|
+
const normalized = mobile.startsWith('00') ? `+${mobile.slice(2)}` : mobile;
|
|
929
885
|
|
|
930
886
|
if (!mobileTargetPattern.test(normalized)) {
|
|
931
|
-
throw new BadRequestException(
|
|
932
|
-
"Invitation mobile number must be E.164-style.",
|
|
933
|
-
);
|
|
887
|
+
throw new BadRequestException('Invitation mobile number must be E.164-style.');
|
|
934
888
|
}
|
|
935
889
|
|
|
936
890
|
return normalized;
|
|
@@ -952,16 +906,13 @@ function toSafeUser(user: User) {
|
|
|
952
906
|
}
|
|
953
907
|
|
|
954
908
|
function invalidInvitationToken() {
|
|
955
|
-
return new UnauthorizedException(
|
|
909
|
+
return new UnauthorizedException('Invalid invitation token.');
|
|
956
910
|
}
|
|
957
911
|
|
|
958
912
|
function invitationStateChanged() {
|
|
959
|
-
return new ConflictException(
|
|
913
|
+
return new ConflictException('Invitation is no longer pending.');
|
|
960
914
|
}
|
|
961
915
|
|
|
962
916
|
function isPrismaUniqueError(error: unknown) {
|
|
963
|
-
return
|
|
964
|
-
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
965
|
-
error.code === "P2002"
|
|
966
|
-
);
|
|
917
|
+
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
|
967
918
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { ApiPropertyOptional } from
|
|
2
|
-
import { IsOptional, IsString, MaxLength, MinLength } from
|
|
3
|
-
import { InvitationTokenDto } from
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
|
3
|
+
import { InvitationTokenDto } from './invitation-token.dto';
|
|
4
4
|
|
|
5
5
|
export class AcceptInvitationDto extends InvitationTokenDto {
|
|
6
6
|
@ApiPropertyOptional({
|
|
7
|
-
description:
|
|
8
|
-
|
|
9
|
-
example: "dev-password-123",
|
|
7
|
+
description: 'Required only when the invitation target is an email with no existing user.',
|
|
8
|
+
example: 'dev-password-123',
|
|
10
9
|
minLength: 8,
|
|
11
10
|
maxLength: 128,
|
|
12
11
|
})
|
|
@@ -16,7 +15,7 @@ export class AcceptInvitationDto extends InvitationTokenDto {
|
|
|
16
15
|
@MaxLength(128)
|
|
17
16
|
password?: string;
|
|
18
17
|
|
|
19
|
-
@ApiPropertyOptional({ example:
|
|
18
|
+
@ApiPropertyOptional({ example: 'New Member', maxLength: 120 })
|
|
20
19
|
@IsOptional()
|
|
21
20
|
@IsString()
|
|
22
21
|
@MaxLength(120)
|