@flusys/nestjs-auth 0.1.0-beta.3 → 1.0.0-rc
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/README.md +274 -301
- package/cjs/config/auth.constants.js +22 -4
- package/cjs/controllers/authentication.controller.js +222 -38
- package/cjs/controllers/branch.controller.js +46 -4
- package/cjs/controllers/company-selection.controller.js +27 -20
- package/cjs/controllers/company.controller.js +46 -4
- package/cjs/controllers/user-permission.controller.js +40 -37
- package/cjs/controllers/user.controller.js +222 -23
- package/cjs/dtos/authentication.dto.js +119 -13
- package/cjs/dtos/user-permission.dto.js +28 -0
- package/cjs/dtos/user.dto.js +19 -4
- package/cjs/entities/index.js +5 -1
- package/cjs/entities/pending-registration.entity.js +125 -0
- package/cjs/entities/user-company-permission.entity.js +0 -6
- package/cjs/entities/user-token.entity.js +129 -0
- package/cjs/guards/auth-throttler.guard.js +53 -0
- package/cjs/interceptors/clear-token.interceptor.js +3 -6
- package/cjs/interceptors/set-token.interceptor.js +38 -31
- package/cjs/interfaces/auth-email-provider.interface.js +45 -0
- package/cjs/interfaces/authentication.interface.js +0 -3
- package/cjs/interfaces/index.js +2 -0
- package/cjs/interfaces/user-enricher.interface.js +4 -0
- package/cjs/modules/auth.module.js +8 -20
- package/cjs/services/auth-config.service.js +42 -47
- package/cjs/services/auth-datasource.provider.js +18 -34
- package/cjs/services/auth-email.service.js +471 -0
- package/cjs/services/authentication.service.js +240 -153
- package/cjs/services/branch.service.js +15 -48
- package/cjs/services/company-selection-session.service.js +1 -1
- package/cjs/services/company.service.js +7 -36
- package/cjs/services/index.js +1 -0
- package/cjs/services/user-permission.service.js +9 -29
- package/cjs/services/user.service.js +228 -107
- package/cjs/strategies/jwt.strategy.js +14 -1
- package/cjs/validators/password.validator.js +45 -0
- package/config/auth.constants.d.ts +5 -0
- package/controllers/authentication.controller.d.ts +13 -9
- package/controllers/company-selection.controller.d.ts +6 -2
- package/controllers/user.controller.d.ts +33 -0
- package/dtos/authentication.dto.d.ts +21 -3
- package/dtos/company-branch.dto.d.ts +0 -1
- package/dtos/company.dto.d.ts +0 -1
- package/dtos/user-permission.dto.d.ts +4 -0
- package/dtos/user.dto.d.ts +1 -2
- package/entities/index.d.ts +7 -3
- package/entities/pending-registration.entity.d.ts +17 -0
- package/entities/user-company-permission.entity.d.ts +0 -2
- package/entities/user-token.entity.d.ts +15 -0
- package/fesm/config/auth.constants.js +11 -8
- package/fesm/controllers/authentication.controller.js +224 -40
- package/fesm/controllers/branch.controller.js +49 -7
- package/fesm/controllers/company-selection.controller.js +28 -21
- package/fesm/controllers/company.controller.js +49 -7
- package/fesm/controllers/user-permission.controller.js +41 -38
- package/fesm/controllers/user.controller.js +225 -26
- package/fesm/dtos/authentication.dto.js +123 -111
- package/fesm/dtos/user-permission.dto.js +28 -0
- package/fesm/dtos/user.dto.js +20 -5
- package/fesm/entities/index.js +7 -1
- package/fesm/entities/pending-registration.entity.js +115 -0
- package/fesm/entities/user-company-permission.entity.js +0 -6
- package/fesm/entities/user-token.entity.js +113 -0
- package/fesm/guards/auth-throttler.guard.js +43 -0
- package/fesm/interceptors/clear-token.interceptor.js +2 -5
- package/fesm/interceptors/set-token.interceptor.js +37 -30
- package/fesm/interfaces/auth-email-provider.interface.js +38 -0
- package/fesm/interfaces/authentication.interface.js +1 -7
- package/fesm/interfaces/index.js +2 -0
- package/fesm/interfaces/user-enricher.interface.js +26 -0
- package/fesm/modules/auth.module.js +10 -22
- package/fesm/services/auth-config.service.js +42 -47
- package/fesm/services/auth-datasource.provider.js +19 -35
- package/fesm/services/auth-email.service.js +420 -0
- package/fesm/services/authentication.service.js +238 -151
- package/fesm/services/branch.service.js +16 -49
- package/fesm/services/company-selection-session.service.js +1 -1
- package/fesm/services/company.service.js +8 -37
- package/fesm/services/index.js +1 -0
- package/fesm/services/user-permission.service.js +10 -30
- package/fesm/services/user.service.js +229 -108
- package/fesm/strategies/jwt.strategy.js +14 -1
- package/fesm/validators/password.validator.js +48 -0
- package/guards/auth-throttler.guard.d.ts +7 -0
- package/interceptors/set-token.interceptor.d.ts +1 -0
- package/interfaces/auth-email-provider.interface.d.ts +6 -0
- package/interfaces/auth-module-options.interface.d.ts +4 -1
- package/interfaces/authentication.interface.d.ts +14 -0
- package/interfaces/index.d.ts +2 -0
- package/interfaces/user-enricher.interface.d.ts +64 -0
- package/interfaces/user.interface.d.ts +1 -0
- package/modules/auth.module.d.ts +0 -1
- package/package.json +4 -4
- package/services/auth-config.service.d.ts +5 -1
- package/services/auth-email.service.d.ts +51 -0
- package/services/authentication.service.d.ts +21 -12
- package/services/branch.service.d.ts +2 -5
- package/services/company-selection-session.service.d.ts +1 -1
- package/services/company.service.d.ts +1 -4
- package/services/index.d.ts +1 -0
- package/services/user-permission.service.d.ts +0 -1
- package/services/user.service.d.ts +20 -6
- package/validators/password.validator.d.ts +10 -0
|
@@ -28,30 +28,23 @@ function _ts_param(paramIndex, decorator) {
|
|
|
28
28
|
import { RequestScopedApiService, HybridCache } from '@flusys/nestjs-shared/classes';
|
|
29
29
|
import { UtilsService } from '@flusys/nestjs-shared/modules';
|
|
30
30
|
import { ErrorHandler } from '@flusys/nestjs-shared/utils';
|
|
31
|
-
import { BadRequestException, Inject, Injectable,
|
|
31
|
+
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, Scope } from '@nestjs/common';
|
|
32
32
|
import * as bcrypt from 'bcrypt';
|
|
33
33
|
import { AuthConfigService } from './auth-config.service';
|
|
34
34
|
import { AuthDataSourceProvider } from './auth-datasource.provider';
|
|
35
|
+
import { BCRYPT_SALT_ROUNDS, USER_ENRICHER } from '../config';
|
|
35
36
|
import { PermissionType } from '../enums';
|
|
37
|
+
import { IUserEnricher } from '../interfaces/user-enricher.interface';
|
|
36
38
|
import { UserCompanyPermission } from '../entities/user-company-permission.entity';
|
|
37
39
|
import { User } from '../entities/user.entity';
|
|
38
40
|
export class UserService extends RequestScopedApiService {
|
|
39
|
-
|
|
40
|
-
* Resolve entity class for this service
|
|
41
|
-
* @returns User entity class
|
|
42
|
-
*/ resolveEntity() {
|
|
41
|
+
resolveEntity() {
|
|
43
42
|
return User;
|
|
44
43
|
}
|
|
45
|
-
|
|
46
|
-
* Get DataSource provider for this service
|
|
47
|
-
* @returns AuthDataSourceProvider instance
|
|
48
|
-
*/ getDataSourceProvider() {
|
|
44
|
+
getDataSourceProvider() {
|
|
49
45
|
return this.dataSourceProvider;
|
|
50
46
|
}
|
|
51
|
-
|
|
52
|
-
* Override ensureRepositoryInitialized to also initialize permission repository and dataSource
|
|
53
|
-
* CRITICAL: Also initializes permissionRepository and dataSource for company feature support
|
|
54
|
-
*/ async ensureRepositoryInitialized() {
|
|
47
|
+
async ensureRepositoryInitialized() {
|
|
55
48
|
await super.ensureRepositoryInitialized();
|
|
56
49
|
// Initialize permission repository if company feature is enabled
|
|
57
50
|
const isCompanyFeatureEnabled = this.authConfig.isCompanyFeatureEnabled();
|
|
@@ -65,26 +58,16 @@ export class UserService extends RequestScopedApiService {
|
|
|
65
58
|
this.dataSource = await this.getDataSourceForService();
|
|
66
59
|
}
|
|
67
60
|
}
|
|
68
|
-
//
|
|
69
|
-
// CUSTOM METHODS (Beyond RequestScopedApiService)
|
|
70
|
-
// ==========================================================================
|
|
61
|
+
// Custom Methods
|
|
71
62
|
/**
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
});
|
|
79
|
-
await this.clearCacheForId([
|
|
80
|
-
{
|
|
81
|
-
id
|
|
82
|
-
}
|
|
83
|
-
]);
|
|
63
|
+
* Validate that the requesting user owns the target profile.
|
|
64
|
+
* Users can only access/modify their own profile data.
|
|
65
|
+
*/ validateProfileOwnership(targetUserId, requestingUser) {
|
|
66
|
+
if (targetUserId !== requestingUser.id) {
|
|
67
|
+
throw new ForbiddenException('Cannot access another user\'s profile');
|
|
68
|
+
}
|
|
84
69
|
}
|
|
85
|
-
/**
|
|
86
|
-
* Verify email
|
|
87
|
-
*/ async verifyEmail(id) {
|
|
70
|
+
/** Verify email */ async verifyEmail(id) {
|
|
88
71
|
// Ensure repository is initialized for custom method
|
|
89
72
|
await this.ensureRepositoryInitialized();
|
|
90
73
|
await this.repository.update(id, {
|
|
@@ -97,9 +80,7 @@ export class UserService extends RequestScopedApiService {
|
|
|
97
80
|
}
|
|
98
81
|
]);
|
|
99
82
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Verify phone
|
|
102
|
-
*/ async verifyPhone(id) {
|
|
83
|
+
/** Verify phone */ async verifyPhone(id) {
|
|
103
84
|
// Ensure repository is initialized for custom method
|
|
104
85
|
await this.ensureRepositoryInitialized();
|
|
105
86
|
await this.repository.update(id, {
|
|
@@ -112,12 +93,7 @@ export class UserService extends RequestScopedApiService {
|
|
|
112
93
|
}
|
|
113
94
|
]);
|
|
114
95
|
}
|
|
115
|
-
/**
|
|
116
|
-
* Update user status
|
|
117
|
-
*
|
|
118
|
-
* When company feature is disabled: Updates User.isActive directly
|
|
119
|
-
* When company feature is enabled: Updates UserCompanyPermission.isActive for the logged user's company
|
|
120
|
-
*/ async updateStatus(id, isActive, user) {
|
|
96
|
+
/** Update user status. When company feature enabled, updates permission isActive for logged user's company. */ async updateStatus(id, isActive, user) {
|
|
121
97
|
// Ensure repository is initialized for custom method
|
|
122
98
|
await this.ensureRepositoryInitialized();
|
|
123
99
|
const isCompanyFeatureEnabled = this.authConfig.isCompanyFeatureEnabled();
|
|
@@ -148,50 +124,68 @@ export class UserService extends RequestScopedApiService {
|
|
|
148
124
|
}
|
|
149
125
|
]);
|
|
150
126
|
}
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
// ==========================================================================
|
|
154
|
-
/**
|
|
155
|
-
* Override: Convert DTO to entity (hash password before saving)
|
|
156
|
-
*/ async convertSingleDtoToEntity(dto, user) {
|
|
127
|
+
// ApiService Overrides
|
|
128
|
+
/** Convert DTO to entity (hash password before saving) */ async convertSingleDtoToEntity(dto, user) {
|
|
157
129
|
// Repository initialization is handled automatically by RequestScopedApiService
|
|
158
130
|
const entity = await super.convertSingleDtoToEntity(dto, user);
|
|
159
|
-
// Hash password if provided
|
|
160
|
-
if (dto.password) {
|
|
161
|
-
entity.password = await bcrypt.hash(dto.password,
|
|
131
|
+
// Hash password if provided (CreateUserDto has password field)
|
|
132
|
+
if ('password' in dto && dto.password) {
|
|
133
|
+
entity.password = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
|
|
162
134
|
}
|
|
163
135
|
return entity;
|
|
164
136
|
}
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
137
|
+
/** Convert entity to response DTO (removes password). In raw mode with company feature, isActive comes from permission table. */ convertEntityToResponseDto(entity, isRaw) {
|
|
138
|
+
if (isRaw) {
|
|
139
|
+
return this.convertRawToResponseDto(entity);
|
|
140
|
+
}
|
|
141
|
+
return this.convertEntityToDto(entity);
|
|
142
|
+
}
|
|
143
|
+
/** Convert raw query result to response DTO */ convertRawToResponseDto(raw) {
|
|
171
144
|
// When raw mode is used with company feature, isActive comes from computed field
|
|
172
|
-
const isActive =
|
|
145
|
+
const isActive = raw.user_companyIsActive !== undefined ? Boolean(raw.user_companyIsActive) : raw.user_is_active;
|
|
173
146
|
return {
|
|
174
|
-
id:
|
|
175
|
-
name:
|
|
176
|
-
phone:
|
|
177
|
-
email:
|
|
178
|
-
profilePictureId:
|
|
147
|
+
id: raw.user_id,
|
|
148
|
+
name: raw.user_name,
|
|
149
|
+
phone: raw.user_phone,
|
|
150
|
+
email: raw.user_email,
|
|
151
|
+
profilePictureId: raw.user_profile_picture_id,
|
|
179
152
|
isActive,
|
|
180
|
-
emailVerified:
|
|
181
|
-
phoneVerified:
|
|
182
|
-
lastLoginAt:
|
|
183
|
-
additionalFields:
|
|
184
|
-
createdAt:
|
|
185
|
-
updatedAt:
|
|
186
|
-
deletedAt:
|
|
187
|
-
createdById:
|
|
188
|
-
updatedById:
|
|
189
|
-
deletedById:
|
|
153
|
+
emailVerified: raw.user_email_verified,
|
|
154
|
+
phoneVerified: raw.user_phone_verified,
|
|
155
|
+
lastLoginAt: raw.user_last_login_at,
|
|
156
|
+
additionalFields: raw.user_additional_fields,
|
|
157
|
+
createdAt: raw.user_created_at,
|
|
158
|
+
updatedAt: raw.user_updated_at,
|
|
159
|
+
deletedAt: raw.user_deleted_at,
|
|
160
|
+
createdById: raw.user_created_by_id,
|
|
161
|
+
updatedById: raw.user_updated_by_id,
|
|
162
|
+
deletedById: raw.user_deleted_by_id
|
|
190
163
|
};
|
|
191
164
|
}
|
|
192
|
-
/**
|
|
193
|
-
|
|
194
|
-
|
|
165
|
+
/** Convert entity to response DTO */ convertEntityToDto(entity) {
|
|
166
|
+
return {
|
|
167
|
+
id: entity.id,
|
|
168
|
+
name: entity.name,
|
|
169
|
+
phone: entity.phone,
|
|
170
|
+
email: entity.email,
|
|
171
|
+
profilePictureId: entity.profilePictureId,
|
|
172
|
+
isActive: entity.isActive,
|
|
173
|
+
emailVerified: entity.emailVerified,
|
|
174
|
+
phoneVerified: entity.phoneVerified,
|
|
175
|
+
lastLoginAt: entity.lastLoginAt,
|
|
176
|
+
additionalFields: entity.additionalFields,
|
|
177
|
+
createdAt: entity.createdAt,
|
|
178
|
+
updatedAt: entity.updatedAt,
|
|
179
|
+
deletedAt: entity.deletedAt,
|
|
180
|
+
createdById: entity.createdById,
|
|
181
|
+
updatedById: entity.updatedById,
|
|
182
|
+
deletedById: entity.deletedById
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Convert entity list to response DTO list */ convertEntityListToResponseListDto(entities, isRaw) {
|
|
186
|
+
return entities.map((entity)=>this.convertEntityToResponseDto(entity, isRaw));
|
|
187
|
+
}
|
|
188
|
+
/** Global search - search by name, email, or phone */ async getGlobalSearchQuery(query, search, _user) {
|
|
195
189
|
query.andWhere('(user.name LIKE :search OR user.email LIKE :search OR user.phone LIKE :search)', {
|
|
196
190
|
search: `%${search}%`
|
|
197
191
|
});
|
|
@@ -200,18 +194,13 @@ export class UserService extends RequestScopedApiService {
|
|
|
200
194
|
isRaw: false
|
|
201
195
|
};
|
|
202
196
|
}
|
|
203
|
-
/**
|
|
204
|
-
* Override: getExtraManipulateQuery - Add company permission-based isActive check when company feature enabled
|
|
205
|
-
*
|
|
206
|
-
* Note: When company feature is disabled at bootstrap, this method returns without modifications.
|
|
207
|
-
* All company-related filtering is skipped, and users are returned normally with isActive from User entity.
|
|
208
|
-
*
|
|
209
|
-
* When company feature is enabled:
|
|
210
|
-
* - isActive is computed from UserCompanyPermission table
|
|
211
|
-
* - Returns isRaw: true to use raw query results
|
|
212
|
-
*/ async getExtraManipulateQuery(query, filterAndPaginationDto, user) {
|
|
197
|
+
/** Add company permission-based isActive check when company feature enabled. Returns isRaw: true for computed fields. */ async getExtraManipulateQuery(query, filterAndPaginationDto, user) {
|
|
213
198
|
// Repository initialization is handled automatically by RequestScopedApiService
|
|
214
|
-
|
|
199
|
+
let result = await super.getExtraManipulateQuery(query, filterAndPaginationDto, user);
|
|
200
|
+
// Apply user enricher query modifications if available
|
|
201
|
+
if (this.userEnricher?.enrichListQuery) {
|
|
202
|
+
await this.userEnricher.enrichListQuery(query, user);
|
|
203
|
+
}
|
|
215
204
|
// Only apply company filtering if:
|
|
216
205
|
// 1. Company feature is enabled at bootstrap
|
|
217
206
|
// 2. User has company context
|
|
@@ -264,12 +253,7 @@ export class UserService extends RequestScopedApiService {
|
|
|
264
253
|
}
|
|
265
254
|
return result;
|
|
266
255
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Override: insert - Create company permission when company feature is enabled
|
|
269
|
-
*
|
|
270
|
-
* Note: When company feature is disabled at bootstrap, this method falls back to parent implementation.
|
|
271
|
-
* No company permissions are created, and user is inserted normally.
|
|
272
|
-
*/ async insert(dto, user) {
|
|
256
|
+
/** Insert user. Creates company permission when company feature is enabled. */ async insert(dto, user) {
|
|
273
257
|
// Repository initialization is handled automatically by RequestScopedApiService
|
|
274
258
|
// Check if company feature is enabled
|
|
275
259
|
const isCompanyFeatureEnabled = this.authConfig.isCompanyFeatureEnabled();
|
|
@@ -337,17 +321,143 @@ export class UserService extends RequestScopedApiService {
|
|
|
337
321
|
}
|
|
338
322
|
}
|
|
339
323
|
/**
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
|
|
324
|
+
* Get user profile with extras from enricher
|
|
325
|
+
* Returns base user data plus any extra data provided by enricher (roles, permissions, etc.)
|
|
326
|
+
*/ async getProfileWithExtras(userId, user) {
|
|
327
|
+
this.validateProfileOwnership(userId, user);
|
|
328
|
+
await this.ensureRepositoryInitialized();
|
|
329
|
+
const userEntity = await this.repository.findOne({
|
|
330
|
+
where: {
|
|
331
|
+
id: userId
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
if (!userEntity) {
|
|
335
|
+
throw new NotFoundException('User not found');
|
|
336
|
+
}
|
|
337
|
+
const userData = this.convertEntityToResponseDto(userEntity, false);
|
|
338
|
+
// Get extras from enricher if available
|
|
339
|
+
let extras;
|
|
340
|
+
if (this.userEnricher?.getProfileExtras) {
|
|
341
|
+
extras = await this.userEnricher.getProfileExtras(userId, user);
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
user: userData,
|
|
345
|
+
extras
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Get enriched user list
|
|
350
|
+
* Applies enricher transformations after query execution
|
|
351
|
+
*/ async getEnrichedList(users, user) {
|
|
352
|
+
if (this.userEnricher?.enrichListItems) {
|
|
353
|
+
return this.userEnricher.enrichListItems(users, user);
|
|
354
|
+
}
|
|
355
|
+
return users;
|
|
356
|
+
}
|
|
357
|
+
// ============================================
|
|
358
|
+
// Multi-Step Profile Methods (via Enricher)
|
|
359
|
+
// ============================================
|
|
360
|
+
/**
|
|
361
|
+
* Get profile section definitions from enricher
|
|
362
|
+
*/ async getProfileSections() {
|
|
363
|
+
if (this.userEnricher?.getProfileSections) {
|
|
364
|
+
return this.userEnricher.getProfileSections();
|
|
365
|
+
}
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Get specific profile section data
|
|
370
|
+
*/ async getProfileSectionData(userId, sectionId, user) {
|
|
371
|
+
this.validateProfileOwnership(userId, user);
|
|
372
|
+
await this.ensureRepositoryInitialized();
|
|
373
|
+
// Verify user exists
|
|
374
|
+
const userEntity = await this.repository.findOne({
|
|
375
|
+
where: {
|
|
376
|
+
id: userId
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
if (!userEntity) {
|
|
380
|
+
throw new NotFoundException('User not found');
|
|
381
|
+
}
|
|
382
|
+
if (this.userEnricher?.getProfileSectionData) {
|
|
383
|
+
return this.userEnricher.getProfileSectionData(userId, sectionId, user);
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Update specific profile section
|
|
389
|
+
*/ async updateProfileSection(userId, sectionId, data, user) {
|
|
390
|
+
this.validateProfileOwnership(userId, user);
|
|
391
|
+
await this.ensureRepositoryInitialized();
|
|
343
392
|
if (!this.dataSource) {
|
|
344
393
|
throw new BadRequestException('DataSource not available');
|
|
345
394
|
}
|
|
395
|
+
// Verify user exists
|
|
396
|
+
const userEntity = await this.repository.findOne({
|
|
397
|
+
where: {
|
|
398
|
+
id: userId
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
if (!userEntity) {
|
|
402
|
+
throw new NotFoundException('User not found');
|
|
403
|
+
}
|
|
404
|
+
if (!this.userEnricher?.updateProfileSection) {
|
|
405
|
+
throw new BadRequestException('Profile sections not supported');
|
|
406
|
+
}
|
|
407
|
+
const queryRunner = this.dataSource.createQueryRunner();
|
|
408
|
+
await queryRunner.connect();
|
|
409
|
+
await queryRunner.startTransaction();
|
|
410
|
+
try {
|
|
411
|
+
await this.userEnricher.updateProfileSection(userId, sectionId, data, queryRunner);
|
|
412
|
+
await queryRunner.commitTransaction();
|
|
413
|
+
await this.clearCacheForId([
|
|
414
|
+
userEntity
|
|
415
|
+
]);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
await queryRunner.rollbackTransaction();
|
|
418
|
+
ErrorHandler.logError(this.logger, error, 'updateProfileSection', {
|
|
419
|
+
entity: this.entityName,
|
|
420
|
+
sectionId
|
|
421
|
+
});
|
|
422
|
+
ErrorHandler.rethrowError(error);
|
|
423
|
+
} finally{
|
|
424
|
+
await queryRunner.release();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Get profile completion percentage
|
|
429
|
+
*/ async getProfileCompletion(userId, user) {
|
|
430
|
+
this.validateProfileOwnership(userId, user);
|
|
431
|
+
await this.ensureRepositoryInitialized();
|
|
432
|
+
// Verify user exists
|
|
433
|
+
const userEntity = await this.repository.findOne({
|
|
434
|
+
where: {
|
|
435
|
+
id: userId
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
if (!userEntity) {
|
|
439
|
+
throw new NotFoundException('User not found');
|
|
440
|
+
}
|
|
441
|
+
if (this.userEnricher?.calculateProfileCompletion) {
|
|
442
|
+
return this.userEnricher.calculateProfileCompletion(userId, user);
|
|
443
|
+
}
|
|
444
|
+
return 100; // Default to 100% if no enricher
|
|
445
|
+
}
|
|
446
|
+
/** Update user profile with password handling and extras */ async profile(dto, user) {
|
|
447
|
+
// Ensure repository and dataSource are initialized
|
|
448
|
+
await this.ensureRepositoryInitialized();
|
|
449
|
+
if (!this.dataSource) {
|
|
450
|
+
throw new BadRequestException('DataSource not available');
|
|
451
|
+
}
|
|
452
|
+
const userId = dto.id ?? user.id;
|
|
453
|
+
// Validate extras if enricher provides validation
|
|
454
|
+
if (dto.extras && this.userEnricher?.validateProfileExtras) {
|
|
455
|
+
await this.userEnricher.validateProfileExtras(userId, dto.extras, user);
|
|
456
|
+
}
|
|
346
457
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
347
458
|
await queryRunner.connect();
|
|
348
459
|
await queryRunner.startTransaction();
|
|
349
460
|
try {
|
|
350
|
-
const userId = dto.id ?? user.id;
|
|
351
461
|
const findUser = await queryRunner.manager.findOne(this.repository.target, {
|
|
352
462
|
where: {
|
|
353
463
|
id: userId
|
|
@@ -358,18 +468,28 @@ export class UserService extends RequestScopedApiService {
|
|
|
358
468
|
}
|
|
359
469
|
// Handle password update
|
|
360
470
|
if (dto.password) {
|
|
361
|
-
// If updating own profile, require old password
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
471
|
+
// If updating own profile, require old password verification
|
|
472
|
+
const isOwnProfile = !dto.id || dto.id === user.id;
|
|
473
|
+
if (isOwnProfile) {
|
|
474
|
+
if (!dto.oldPassword) {
|
|
475
|
+
throw new BadRequestException('Current password is required to change your password');
|
|
476
|
+
}
|
|
477
|
+
const isPasswordValid = await bcrypt.compare(dto.oldPassword, findUser.password);
|
|
478
|
+
if (!isPasswordValid) {
|
|
479
|
+
throw new BadRequestException('Current password is incorrect');
|
|
480
|
+
}
|
|
365
481
|
}
|
|
366
|
-
|
|
367
|
-
findUser.password = await bcrypt.hash(dto.password, salt);
|
|
482
|
+
findUser.password = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
|
|
368
483
|
}
|
|
369
|
-
// Merge other fields (excluding password)
|
|
370
|
-
|
|
484
|
+
// Merge other fields (excluding password, oldPassword, extras, and sensitive flags)
|
|
485
|
+
// Users cannot self-modify emailVerified, phoneVerified, or isActive via profile update
|
|
486
|
+
const { password, oldPassword, extras, emailVerified, phoneVerified, isActive, ...restDto } = dto;
|
|
371
487
|
Object.assign(findUser, restDto);
|
|
372
488
|
await queryRunner.manager.save(this.repository.target, findUser);
|
|
489
|
+
// Handle extras update via enricher
|
|
490
|
+
if (extras && this.userEnricher?.updateProfileExtras) {
|
|
491
|
+
await this.userEnricher.updateProfileExtras(userId, extras, queryRunner);
|
|
492
|
+
}
|
|
373
493
|
await queryRunner.commitTransaction();
|
|
374
494
|
await this.clearCacheForId([
|
|
375
495
|
findUser
|
|
@@ -385,10 +505,8 @@ export class UserService extends RequestScopedApiService {
|
|
|
385
505
|
await queryRunner.release();
|
|
386
506
|
}
|
|
387
507
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
// Repository will be set asynchronously by RequestScopedApiService
|
|
391
|
-
super('user', null, cacheManager, utilsService, UserService.name, true), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), _define_property(this, "dataSourceProvider", void 0), _define_property(this, "authConfig", void 0), _define_property(this, "logger", void 0), _define_property(this, "permissionRepository", void 0), _define_property(this, "dataSource", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService, this.dataSourceProvider = dataSourceProvider, this.authConfig = authConfig, this.logger = new Logger(UserService.name);
|
|
508
|
+
constructor(cacheManager, utilsService, dataSourceProvider, authConfig, userEnricher){
|
|
509
|
+
super('user', null, cacheManager, utilsService, UserService.name, true), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), _define_property(this, "dataSourceProvider", void 0), _define_property(this, "authConfig", void 0), _define_property(this, "userEnricher", void 0), _define_property(this, "permissionRepository", void 0), _define_property(this, "dataSource", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService, this.dataSourceProvider = dataSourceProvider, this.authConfig = authConfig, this.userEnricher = userEnricher;
|
|
392
510
|
}
|
|
393
511
|
}
|
|
394
512
|
UserService = _ts_decorate([
|
|
@@ -399,11 +517,14 @@ UserService = _ts_decorate([
|
|
|
399
517
|
_ts_param(1, Inject(UtilsService)),
|
|
400
518
|
_ts_param(2, Inject(AuthDataSourceProvider)),
|
|
401
519
|
_ts_param(3, Inject(AuthConfigService)),
|
|
520
|
+
_ts_param(4, Optional()),
|
|
521
|
+
_ts_param(4, Inject(USER_ENRICHER)),
|
|
402
522
|
_ts_metadata("design:type", Function),
|
|
403
523
|
_ts_metadata("design:paramtypes", [
|
|
404
524
|
typeof HybridCache === "undefined" ? Object : HybridCache,
|
|
405
525
|
typeof UtilsService === "undefined" ? Object : UtilsService,
|
|
406
526
|
typeof AuthDataSourceProvider === "undefined" ? Object : AuthDataSourceProvider,
|
|
407
|
-
typeof AuthConfigService === "undefined" ? Object : AuthConfigService
|
|
527
|
+
typeof AuthConfigService === "undefined" ? Object : AuthConfigService,
|
|
528
|
+
typeof IUserEnricher === "undefined" ? Object : IUserEnricher
|
|
408
529
|
])
|
|
409
530
|
], UserService);
|
|
@@ -26,6 +26,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
|
|
26
26
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
27
27
|
import { AuthConfigService } from '../services/auth-config.service';
|
|
28
28
|
import { AuthDataSourceProvider } from '../services/auth-datasource.provider';
|
|
29
|
+
import { AuthenticationService } from '../services/authentication.service';
|
|
29
30
|
import { User } from '../entities/user.entity';
|
|
30
31
|
/**
|
|
31
32
|
* Type guard to check if payload includes company context
|
|
@@ -34,8 +35,9 @@ import { User } from '../entities/user.entity';
|
|
|
34
35
|
}
|
|
35
36
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|
36
37
|
async validate(_req, payload) {
|
|
37
|
-
// Resolve request-scoped
|
|
38
|
+
// Resolve request-scoped services for this request
|
|
38
39
|
let userRepository = null;
|
|
40
|
+
let authService = null;
|
|
39
41
|
try {
|
|
40
42
|
const dataSourceProvider = await this.moduleRef.resolve(AuthDataSourceProvider, undefined, {
|
|
41
43
|
strict: false
|
|
@@ -43,12 +45,23 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|
|
43
45
|
if (dataSourceProvider) {
|
|
44
46
|
userRepository = await dataSourceProvider.getRepository(User);
|
|
45
47
|
}
|
|
48
|
+
// Resolve AuthenticationService for token revocation check
|
|
49
|
+
authService = await this.moduleRef.resolve(AuthenticationService, undefined, {
|
|
50
|
+
strict: false
|
|
51
|
+
});
|
|
46
52
|
} catch (error) {
|
|
47
53
|
throw new UnauthorizedException('Unable to access user data');
|
|
48
54
|
}
|
|
49
55
|
if (!userRepository) {
|
|
50
56
|
throw new UnauthorizedException('User repository not available');
|
|
51
57
|
}
|
|
58
|
+
// Check if token is revoked (logged out)
|
|
59
|
+
if (authService && payload.iat) {
|
|
60
|
+
const isRevoked = await authService.isTokenRevoked(payload.id, payload.iat);
|
|
61
|
+
if (isRevoked) {
|
|
62
|
+
throw new UnauthorizedException('Token has been revoked');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
52
65
|
// Find user and check if active
|
|
53
66
|
const user = await userRepository.findOne({
|
|
54
67
|
where: {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { registerDecorator } from 'class-validator';
|
|
2
|
+
/**
|
|
3
|
+
* Strong password validation decorator.
|
|
4
|
+
* Validates that passwords meet complexity requirements to prevent weak passwords.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* @IsStrongPassword()
|
|
9
|
+
* password!: string;
|
|
10
|
+
*
|
|
11
|
+
* // With custom options
|
|
12
|
+
* @IsStrongPassword({ minLength: 12, requireSpecialChar: false })
|
|
13
|
+
* password!: string;
|
|
14
|
+
* ```
|
|
15
|
+
*/ export function IsStrongPassword(options = {}, validationOptions) {
|
|
16
|
+
const { minLength = 8, maxLength = 128, requireUppercase = true, requireLowercase = true, requireNumber = true, requireSpecialChar = true } = options;
|
|
17
|
+
return function(object, propertyName) {
|
|
18
|
+
registerDecorator({
|
|
19
|
+
name: 'isStrongPassword',
|
|
20
|
+
target: object.constructor,
|
|
21
|
+
propertyName: propertyName,
|
|
22
|
+
options: validationOptions,
|
|
23
|
+
validator: {
|
|
24
|
+
validate (value) {
|
|
25
|
+
if (typeof value !== 'string') return false;
|
|
26
|
+
const checks = [
|
|
27
|
+
value.length >= minLength,
|
|
28
|
+
value.length <= maxLength,
|
|
29
|
+
!requireUppercase || /[A-Z]/.test(value),
|
|
30
|
+
!requireLowercase || /[a-z]/.test(value),
|
|
31
|
+
!requireNumber || /[0-9]/.test(value),
|
|
32
|
+
!requireSpecialChar || /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~`]/.test(value)
|
|
33
|
+
];
|
|
34
|
+
return checks.every(Boolean);
|
|
35
|
+
},
|
|
36
|
+
defaultMessage (args) {
|
|
37
|
+
const requirements = [];
|
|
38
|
+
if (minLength > 0) requirements.push(`at least ${minLength} characters`);
|
|
39
|
+
if (requireUppercase) requirements.push('one uppercase letter');
|
|
40
|
+
if (requireLowercase) requirements.push('one lowercase letter');
|
|
41
|
+
if (requireNumber) requirements.push('one number');
|
|
42
|
+
if (requireSpecialChar) requirements.push('one special character (!@#$%^&*...)');
|
|
43
|
+
return `Password must contain ${requirements.join(', ')}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { ThrottlerGuard } from '@nestjs/throttler';
|
|
3
|
+
export declare class AuthThrottlerGuard extends ThrottlerGuard {
|
|
4
|
+
protected getTracker(req: Record<string, unknown>): Promise<string>;
|
|
5
|
+
protected throwThrottlingException(): Promise<void>;
|
|
6
|
+
protected shouldSkip(context: ExecutionContext): Promise<boolean>;
|
|
7
|
+
}
|
|
@@ -5,6 +5,7 @@ export declare class SetToken implements NestInterceptor {
|
|
|
5
5
|
private readonly authConfig?;
|
|
6
6
|
constructor(authConfig?: AuthConfigService);
|
|
7
7
|
private get cookieName();
|
|
8
|
+
private getMaxAgeMs;
|
|
8
9
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
9
10
|
private isBrowserRequest;
|
|
10
11
|
private setRefreshTokenCookie;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface IAuthEmailProvider {
|
|
2
|
+
sendPasswordResetEmail(email: string, token: string, resetUrl: string): Promise<void>;
|
|
3
|
+
sendVerificationEmail(email: string, token: string, verifyUrl: string): Promise<void>;
|
|
4
|
+
sendWelcomeEmail?(email: string, name: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare const AUTH_EMAIL_PROVIDER = "AUTH_EMAIL_PROVIDER";
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { IBootstrapAppConfig, IDataSourceServiceOptions, IDynamicModuleConfig, IModuleOptionsFactory } from '@flusys/nestjs-core';
|
|
2
|
-
import { ModuleMetadata, Type } from '@nestjs/common';
|
|
2
|
+
import { ModuleMetadata, Provider, Type } from '@nestjs/common';
|
|
3
3
|
export interface IAuthModuleConfig extends IDataSourceServiceOptions {
|
|
4
4
|
jwtSecret?: string;
|
|
5
5
|
jwtExpiration?: string;
|
|
6
6
|
refreshTokenSecret?: string;
|
|
7
7
|
refreshTokenExpiration?: string;
|
|
8
8
|
refreshTokenCookieName?: string;
|
|
9
|
+
frontendUrl?: string;
|
|
9
10
|
}
|
|
10
11
|
export interface AuthModuleOptions extends IDynamicModuleConfig {
|
|
11
12
|
bootstrapAppConfig?: IBootstrapAppConfig;
|
|
12
13
|
config?: IAuthModuleConfig;
|
|
14
|
+
providers?: Provider[];
|
|
13
15
|
}
|
|
14
16
|
export interface AuthOptionsFactory extends IModuleOptionsFactory<IAuthModuleConfig> {
|
|
15
17
|
createAuthOptions(): Promise<IAuthModuleConfig> | IAuthModuleConfig;
|
|
@@ -21,4 +23,5 @@ export interface AuthModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'>,
|
|
|
21
23
|
inject?: any[];
|
|
22
24
|
useClass?: Type<AuthOptionsFactory>;
|
|
23
25
|
useExisting?: Type<AuthOptionsFactory>;
|
|
26
|
+
providers?: Provider[];
|
|
24
27
|
}
|
|
@@ -51,3 +51,17 @@ export interface LoginResponseRequiresSelection {
|
|
|
51
51
|
user: IUserMini;
|
|
52
52
|
companies: CompanyWithBranches[];
|
|
53
53
|
}
|
|
54
|
+
export interface RegistrationResponse {
|
|
55
|
+
accessToken?: string;
|
|
56
|
+
refreshToken?: string;
|
|
57
|
+
user: IUserMini;
|
|
58
|
+
company?: ICompanyMini;
|
|
59
|
+
branch?: ICompanyBranchMini;
|
|
60
|
+
companyFeatureEnabled?: boolean;
|
|
61
|
+
requiresEmailVerification?: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface LoginResponseRequiresEmailVerification {
|
|
64
|
+
requiresEmailVerification: true;
|
|
65
|
+
email: string;
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
package/interfaces/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './authentication.interface';
|
|
2
|
+
export * from './auth-email-provider.interface';
|
|
2
3
|
export * from './company-branch.interface';
|
|
3
4
|
export * from './company.interface';
|
|
4
5
|
export * from './user.interface';
|
|
6
|
+
export * from './user-enricher.interface';
|
|
5
7
|
export * from './auth-module-options.interface';
|