@flusys/nestjs-auth 6.4.0 → 7.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/cjs/config/event-actions.js +45 -0
  2. package/cjs/config/index.js +1 -0
  3. package/cjs/config/message-keys.js +12 -0
  4. package/cjs/modules/auth.module.js +2 -1
  5. package/cjs/services/auth-email.service.js +21 -0
  6. package/cjs/services/auth-email.service.spec.js +52 -0
  7. package/cjs/services/authentication.service.js +62 -4
  8. package/cjs/services/authentication.service.spec.js +58 -0
  9. package/cjs/services/branch.service.js +20 -0
  10. package/cjs/services/branch.service.spec.js +15 -0
  11. package/cjs/services/company.service.js +20 -0
  12. package/cjs/services/company.service.spec.js +14 -0
  13. package/cjs/services/user-permission.service.js +20 -0
  14. package/cjs/services/user-permission.service.spec.js +52 -0
  15. package/cjs/services/user.service.js +36 -18
  16. package/cjs/services/user.service.spec.js +15 -1
  17. package/config/event-actions.d.ts +21 -0
  18. package/config/index.d.ts +1 -0
  19. package/config/message-keys.d.ts +6 -0
  20. package/fesm/config/event-actions.js +24 -0
  21. package/fesm/config/index.js +1 -0
  22. package/fesm/config/message-keys.js +6 -0
  23. package/fesm/modules/auth.module.js +3 -2
  24. package/fesm/services/auth-email.service.js +22 -1
  25. package/fesm/services/auth-email.service.spec.js +52 -0
  26. package/fesm/services/authentication.service.js +64 -6
  27. package/fesm/services/authentication.service.spec.js +58 -0
  28. package/fesm/services/branch.service.js +22 -2
  29. package/fesm/services/branch.service.spec.js +15 -0
  30. package/fesm/services/company.service.js +22 -2
  31. package/fesm/services/company.service.spec.js +14 -0
  32. package/fesm/services/user-permission.service.js +21 -1
  33. package/fesm/services/user-permission.service.spec.js +52 -0
  34. package/fesm/services/user.service.js +36 -18
  35. package/fesm/services/user.service.spec.js +15 -1
  36. package/interfaces/auth-module-options.interface.d.ts +2 -0
  37. package/package.json +3 -3
  38. package/services/auth-email.service.d.ts +1 -0
  39. package/services/authentication.service.d.ts +3 -0
  40. package/services/user-permission.service.d.ts +1 -0
@@ -10,6 +10,7 @@ describe('BranchService', ()=>{
10
10
  let mockQueryRunner;
11
11
  beforeEach(()=>{
12
12
  mockRepo = createMockRepository();
13
+ mockRepo.find.mockResolvedValue([]);
13
14
  Object.assign(mockRepo.createQueryBuilder(), {
14
15
  leftJoinAndSelect: jest.fn().mockReturnThis(),
15
16
  addOrderBy: jest.fn().mockReturnThis()
@@ -78,6 +79,20 @@ describe('BranchService', ()=>{
78
79
  }, null, mockQueryRunner);
79
80
  expect(mockPermissionSyncAdapter.onBranchDeleted).toHaveBeenCalledWith('branch-1', 'company-1');
80
81
  });
82
+ it('rejects deleting a readonly branch', async ()=>{
83
+ mockRepo.find.mockResolvedValue([
84
+ {
85
+ id: 'branch-1',
86
+ name: 'Default Branch',
87
+ readOnly: true
88
+ }
89
+ ]);
90
+ const permissionRepo = mockQueryRunner.manager.getRepository(UserCompanyPermission);
91
+ await expect(service.beforeDeleteOperation({
92
+ id: 'branch-1'
93
+ }, null, mockQueryRunner)).rejects.toThrow('Cannot delete readonly branch');
94
+ expect(permissionRepo.delete).not.toHaveBeenCalled();
95
+ });
81
96
  });
82
97
  describe('getSelectQuery', ()=>{
83
98
  it('applies the default selected fields when none are provided', async ()=>{
@@ -28,8 +28,9 @@ function _ts_param(paramIndex, decorator) {
28
28
  import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
29
29
  import { IPermissionSyncAdapter, PERMISSION_SYNC_ADAPTER } from '@flusys/nestjs-shared/interfaces';
30
30
  import { UtilsService } from '@flusys/nestjs-shared/modules';
31
- import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
32
- import { In } from 'typeorm';
31
+ import { ForbiddenException, Inject, Injectable, Optional, Scope } from '@nestjs/common';
32
+ import { In, IsNull } from 'typeorm';
33
+ import { COMPANY_MESSAGES } from '../config';
33
34
  import { CompanyBranch } from '../entities/company-branch.entity';
34
35
  import { Company } from '../entities/company.entity';
35
36
  import { UserCompanyPermission } from '../entities/user-company-permission.entity';
@@ -39,6 +40,24 @@ export class CompanyService extends ApiService {
39
40
  const companyIds = Array.isArray(dto.id) ? dto.id : [
40
41
  dto.id
41
42
  ];
43
+ await this.ensureDataSourceRepository();
44
+ const readonlyCompanies = await this.repository.find({
45
+ where: {
46
+ id: In(companyIds),
47
+ readOnly: true,
48
+ deletedAt: IsNull()
49
+ }
50
+ });
51
+ if (readonlyCompanies.length > 0) {
52
+ const names = readonlyCompanies.map((c)=>c.name).join(', ');
53
+ throw new ForbiddenException({
54
+ message: `Cannot delete readonly company(ies): ${names}`,
55
+ messageKey: COMPANY_MESSAGES.READONLY_DELETE_FORBIDDEN,
56
+ messageVariables: {
57
+ names
58
+ }
59
+ });
60
+ }
42
61
  const branchRows = await queryRunner.manager.getRepository(CompanyBranch).find({
43
62
  where: {
44
63
  companyId: In(companyIds)
@@ -69,6 +88,7 @@ export class CompanyService extends ApiService {
69
88
  'email',
70
89
  'phone',
71
90
  'isActive',
91
+ 'readOnly',
72
92
  'createdAt'
73
93
  ];
74
94
  }
@@ -11,6 +11,7 @@ describe('CompanyService', ()=>{
11
11
  let permissionRepo;
12
12
  beforeEach(()=>{
13
13
  mockRepo = createMockRepository();
14
+ mockRepo.find.mockResolvedValue([]);
14
15
  const mockDataSourceProvider = createMockDataSourceProvider(mockRepo);
15
16
  mockPermissionSyncAdapter = {
16
17
  onCompanyDeleted: jest.fn()
@@ -75,6 +76,19 @@ describe('CompanyService', ()=>{
75
76
  expect(mockPermissionSyncAdapter.onCompanyDeleted).toHaveBeenCalledWith('company-1');
76
77
  expect(mockPermissionSyncAdapter.onCompanyDeleted).toHaveBeenCalledWith('company-2');
77
78
  });
79
+ it('rejects deleting a readonly company', async ()=>{
80
+ mockRepo.find.mockResolvedValue([
81
+ {
82
+ id: 'company-1',
83
+ name: 'Default Company',
84
+ readOnly: true
85
+ }
86
+ ]);
87
+ await expect(service.beforeDeleteOperation({
88
+ id: 'company-1'
89
+ }, null, mockQueryRunner)).rejects.toThrow('Cannot delete readonly company');
90
+ expect(branchRepo.find).not.toHaveBeenCalled();
91
+ });
78
92
  });
79
93
  describe('getSelectQuery', ()=>{
80
94
  it('applies the default selected fields when none are provided', async ()=>{
@@ -26,9 +26,10 @@ function _ts_param(paramIndex, decorator) {
26
26
  };
27
27
  }
28
28
  import { LogAction } from '@flusys/nestjs-shared';
29
+ import { publishDomainEvent } from '@flusys/nestjs-shared/classes';
29
30
  import { IPermissionSyncAdapter, PERMISSION_SYNC_ADAPTER } from '@flusys/nestjs-shared/interfaces';
30
31
  import { ConflictException, Inject, Injectable, NotFoundException, Optional, Scope } from '@nestjs/common';
31
- import { USER_PERMISSION_MESSAGES } from '../config';
32
+ import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, USER_PERMISSION_MESSAGES } from '../config';
32
33
  import { PermissionType, UserCompanyPermission } from '../entities/user-company-permission.entity';
33
34
  import { AuthDataSourceProvider } from './auth-datasource.provider';
34
35
  export class UserPermissionService {
@@ -92,6 +93,7 @@ export class UserPermissionService {
92
93
  }
93
94
  throw error;
94
95
  }
96
+ await this.publishPermissionEvent(AUTH_EVENT_ACTIONS.PERMISSION_GRANTED, userId, targetId, type);
95
97
  }
96
98
  async revokePermission(userId, targetId, type) {
97
99
  const repository = await this.getRepository();
@@ -124,6 +126,24 @@ export class UserPermissionService {
124
126
  } else {
125
127
  if (companyId) await this.permissionSyncAdapter?.onUserBranchAccessRevoked?.(userId, targetId, companyId);
126
128
  }
129
+ await this.publishPermissionEvent(AUTH_EVENT_ACTIONS.PERMISSION_REVOKED, userId, targetId, type, companyId);
130
+ }
131
+ async publishPermissionEvent(action, userId, targetId, type, companyId) {
132
+ await publishDomainEvent({
133
+ module: AUTH_EVENT_MODULE,
134
+ entity: AUTH_EVENT_ENTITIES.USER_PERMISSION,
135
+ action,
136
+ ids: [
137
+ targetId
138
+ ],
139
+ metadata: {
140
+ targetUserId: userId,
141
+ permissionType: type,
142
+ ...companyId ? {
143
+ companyId
144
+ } : {}
145
+ }
146
+ });
127
147
  }
128
148
  async revokeBranchPermissionsForCompany(repository, userId, companyId) {
129
149
  const branchPermissions = await repository.createQueryBuilder('permission').innerJoin('permission.branch', 'branch').where('permission.userId = :userId', {
@@ -1,4 +1,5 @@
1
1
  import { ConflictException, NotFoundException } from '@nestjs/common';
2
+ import { EventBusRegistry } from '@flusys/nestjs-shared/classes';
2
3
  import { createMockDataSourceProvider, createMockRepository } from '@test-utils/mocks/repository.mock';
3
4
  import { UserPermissionService } from './user-permission.service';
4
5
  import { PermissionType } from '../entities/user-company-permission.entity';
@@ -195,4 +196,55 @@ describe('UserPermissionService', ()=>{
195
196
  });
196
197
  });
197
198
  });
199
+ describe('domain events', ()=>{
200
+ let publish;
201
+ beforeEach(()=>{
202
+ publish = jest.fn().mockResolvedValue(undefined);
203
+ EventBusRegistry.reset();
204
+ EventBusRegistry.setBus({
205
+ publish,
206
+ subscribe: jest.fn(),
207
+ close: jest.fn()
208
+ });
209
+ EventBusRegistry.configureModule('auth', {
210
+ enabled: true
211
+ });
212
+ });
213
+ afterEach(()=>{
214
+ EventBusRegistry.reset();
215
+ });
216
+ it('publishes permission-granted after a new assignment', async ()=>{
217
+ mockRepo.findOne.mockResolvedValue(null);
218
+ mockRepo.create.mockReturnValue(buildPermission());
219
+ mockRepo.save.mockResolvedValue(buildPermission());
220
+ await service.assignPermission('user-1', 'company-1', PermissionType.COMPANY);
221
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({
222
+ name: 'auth.user_company_permission.permission-granted',
223
+ payload: expect.objectContaining({
224
+ ids: [
225
+ 'company-1'
226
+ ],
227
+ metadata: expect.objectContaining({
228
+ targetUserId: 'user-1',
229
+ permissionType: PermissionType.COMPANY
230
+ })
231
+ })
232
+ }));
233
+ });
234
+ it('does not publish when the assignment is rejected as a duplicate', async ()=>{
235
+ mockRepo.findOne.mockResolvedValue(buildPermission());
236
+ await expect(service.assignPermission('user-1', 'company-1', PermissionType.COMPANY)).rejects.toThrow(ConflictException);
237
+ expect(publish).not.toHaveBeenCalled();
238
+ });
239
+ it('does not publish when the auth module has events disabled', async ()=>{
240
+ EventBusRegistry.configureModule('auth', {
241
+ enabled: false
242
+ });
243
+ mockRepo.findOne.mockResolvedValue(null);
244
+ mockRepo.create.mockReturnValue(buildPermission());
245
+ mockRepo.save.mockResolvedValue(buildPermission());
246
+ await service.assignPermission('user-1', 'company-1', PermissionType.COMPANY);
247
+ expect(publish).not.toHaveBeenCalled();
248
+ });
249
+ });
198
250
  });
@@ -79,10 +79,13 @@ export class UserService extends ApiService {
79
79
  await this.repository.update(id, {
80
80
  emailVerified: true
81
81
  });
82
- await this.clearCacheForId([
83
- {
84
- id
85
- }
82
+ await Promise.all([
83
+ this.clearCacheForAll(),
84
+ this.clearCacheForId([
85
+ {
86
+ id
87
+ }
88
+ ])
86
89
  ]);
87
90
  }
88
91
  async verifyPhone(id) {
@@ -90,10 +93,13 @@ export class UserService extends ApiService {
90
93
  await this.repository.update(id, {
91
94
  phoneVerified: true
92
95
  });
93
- await this.clearCacheForId([
94
- {
95
- id
96
- }
96
+ await Promise.all([
97
+ this.clearCacheForAll(),
98
+ this.clearCacheForId([
99
+ {
100
+ id
101
+ }
102
+ ])
97
103
  ]);
98
104
  }
99
105
  async updateStatus(id, isActive, user) {
@@ -123,10 +129,13 @@ export class UserService extends ApiService {
123
129
  isActive
124
130
  });
125
131
  }
126
- await this.clearCacheForId([
127
- {
128
- id
129
- }
132
+ await Promise.all([
133
+ this.clearCacheForAll(),
134
+ this.clearCacheForId([
135
+ {
136
+ id
137
+ }
138
+ ])
130
139
  ]);
131
140
  }
132
141
  async convertSingleDtoToEntity(dto, user) {
@@ -350,8 +359,11 @@ export class UserService extends ApiService {
350
359
  }
351
360
  }
352
361
  await queryRunner.commitTransaction();
353
- await this.clearCacheForId([
354
- savedUser
362
+ await Promise.all([
363
+ this.clearCacheForAll(),
364
+ this.clearCacheForId([
365
+ savedUser
366
+ ])
355
367
  ]);
356
368
  return this.convertEntityToResponseDto(savedUser, false);
357
369
  } catch (error) {
@@ -420,8 +432,11 @@ export class UserService extends ApiService {
420
432
  try {
421
433
  await this.userEnricher.updateProfileSection(userId, sectionId, data, queryRunner);
422
434
  await queryRunner.commitTransaction();
423
- await this.clearCacheForId([
424
- userEntity
435
+ await Promise.all([
436
+ this.clearCacheForAll(),
437
+ this.clearCacheForId([
438
+ userEntity
439
+ ])
425
440
  ]);
426
441
  } catch (error) {
427
442
  await queryRunner.rollbackTransaction();
@@ -490,8 +505,11 @@ export class UserService extends ApiService {
490
505
  await this.userEnricher.updateProfileExtras(userId, extras, queryRunner);
491
506
  }
492
507
  await queryRunner.commitTransaction();
493
- await this.clearCacheForId([
494
- findUser
508
+ await Promise.all([
509
+ this.clearCacheForAll(),
510
+ this.clearCacheForId([
511
+ findUser
512
+ ])
495
513
  ]);
496
514
  return findUser.id;
497
515
  } catch (error) {
@@ -79,6 +79,13 @@ describe('UserService', ()=>{
79
79
  };
80
80
  service = new UserService({}, mockUtilsService, mockDataSourceProvider, mockAuthConfig, mockUserEnricher);
81
81
  });
82
+ /**
83
+ * Every write path has to drop both the list cache and the row's own cache, or a
84
+ * stale user keeps being served from `get-all`.
85
+ */ function expectCacheCleared(id) {
86
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('appuser', expect.anything());
87
+ expect(mockUtilsService.clearCache).toHaveBeenCalledWith('appuser', expect.anything(), id);
88
+ }
82
89
  describe('ensureDataSourceRepository', ()=>{
83
90
  it('does not load the permission repository when the company feature is disabled', async ()=>{
84
91
  await service.ensureDataSourceRepository();
@@ -97,13 +104,14 @@ describe('UserService', ()=>{
97
104
  expect(userRepo.update).toHaveBeenCalledWith('user-1', {
98
105
  emailVerified: true
99
106
  });
100
- expect(mockUtilsService.clearCache).toHaveBeenCalled();
107
+ expectCacheCleared('user-1');
101
108
  });
102
109
  it('marks the phone as verified and clears the cache', async ()=>{
103
110
  await service.verifyPhone('user-1');
104
111
  expect(userRepo.update).toHaveBeenCalledWith('user-1', {
105
112
  phoneVerified: true
106
113
  });
114
+ expectCacheCleared('user-1');
107
115
  });
108
116
  });
109
117
  describe('updateStatus', ()=>{
@@ -112,6 +120,7 @@ describe('UserService', ()=>{
112
120
  expect(userRepo.update).toHaveBeenCalledWith('user-1', {
113
121
  isActive: false
114
122
  });
123
+ expectCacheCleared('user-1');
115
124
  });
116
125
  it('throws NotFoundException when the company permission is missing', async ()=>{
117
126
  companyFeatureEnabled = true;
@@ -134,6 +143,7 @@ describe('UserService', ()=>{
134
143
  isActive: false
135
144
  }));
136
145
  expect(userRepo.update).not.toHaveBeenCalled();
146
+ expectCacheCleared('user-1');
137
147
  });
138
148
  });
139
149
  describe('convertSingleDtoToEntity', ()=>{
@@ -344,6 +354,7 @@ describe('UserService', ()=>{
344
354
  expect(result).toEqual(expect.objectContaining({
345
355
  id: 'user-1'
346
356
  }));
357
+ expectCacheCleared(savedUser.id);
347
358
  });
348
359
  it('rolls back the transaction if permission assignment fails', async ()=>{
349
360
  companyFeatureEnabled = true;
@@ -532,6 +543,7 @@ describe('UserService', ()=>{
532
543
  foo: 'bar'
533
544
  }, queryRunner);
534
545
  expect(queryRunner.commitTransaction).toHaveBeenCalled();
546
+ expectCacheCleared('user-1');
535
547
  });
536
548
  it('rolls back the transaction when the enricher throws', async ()=>{
537
549
  userRepo.findOne.mockResolvedValue(buildAppUser());
@@ -625,6 +637,7 @@ describe('UserService', ()=>{
625
637
  expect(queryRunner.manager.save).toHaveBeenCalled();
626
638
  expect(queryRunner.commitTransaction).toHaveBeenCalled();
627
639
  expect(result).toBe('user-1');
640
+ expectCacheCleared('user-1');
628
641
  });
629
642
  it('does not require the current password when an admin updates another user profile', async ()=>{
630
643
  service.dataSource = mockDataSource;
@@ -641,6 +654,7 @@ describe('UserService', ()=>{
641
654
  }));
642
655
  expect(queryRunner.manager.save).toHaveBeenCalled();
643
656
  expect(queryRunner.commitTransaction).toHaveBeenCalled();
657
+ expectCacheCleared('other-user');
644
658
  });
645
659
  it('rolls back the transaction when save fails', async ()=>{
646
660
  service.dataSource = mockDataSource;
@@ -1,4 +1,5 @@
1
1
  import { IBootstrapAppConfig, IDataSourceServiceOptions, IDynamicModuleConfig, IModuleOptionsFactory } from '@flusys/nestjs-core';
2
+ import { IModuleEventsConfig } from '@flusys/nestjs-shared/interfaces';
2
3
  import { ModuleMetadata, Provider, Type } from '@nestjs/common';
3
4
  export interface IAuthModuleConfig extends IDataSourceServiceOptions {
4
5
  jwtSecret?: string;
@@ -6,6 +7,7 @@ export interface IAuthModuleConfig extends IDataSourceServiceOptions {
6
7
  refreshTokenSecret?: string;
7
8
  refreshTokenExpiration?: string;
8
9
  refreshTokenCookieName?: string;
10
+ events?: IModuleEventsConfig;
9
11
  }
10
12
  export interface AuthModuleOptions extends IDynamicModuleConfig {
11
13
  bootstrapAppConfig?: IBootstrapAppConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-auth",
3
- "version": "6.4.0",
3
+ "version": "7.0.0-beta.1",
4
4
  "description": "Authentication and authorization infrastructure for NestJS applications",
5
5
  "main": "cjs/index.js",
6
6
  "module": "fesm/index.js",
@@ -98,7 +98,7 @@
98
98
  "rxjs": "^7.8.0"
99
99
  },
100
100
  "dependencies": {
101
- "@flusys/nestjs-core": "6.4.0",
102
- "@flusys/nestjs-shared": "6.4.0"
101
+ "@flusys/nestjs-core": "7.0.0-beta.1",
102
+ "@flusys/nestjs-shared": "7.0.0-beta.1"
103
103
  }
104
104
  }
@@ -46,6 +46,7 @@ export declare class AuthEmailService {
46
46
  message: string;
47
47
  messageKey: string;
48
48
  }>;
49
+ private publishUserEvent;
49
50
  resendVerificationEmail(email: string, verifyUrlBase: string): Promise<{
50
51
  success: boolean;
51
52
  message: string;
@@ -61,4 +61,7 @@ export declare class AuthenticationService {
61
61
  private generateCompanySelectionResponse;
62
62
  private generateRegistrationResponse;
63
63
  private generateRegistrationResponseWithCompany;
64
+ private publishSessionEvent;
65
+ private publishUserEvent;
66
+ private publishAuthEvent;
64
67
  }
@@ -10,6 +10,7 @@ export declare class UserPermissionService {
10
10
  findUserPermissions(userId: string, type: PermissionType): Promise<UserCompanyPermission[]>;
11
11
  assignPermission(userId: string, targetId: string, type: PermissionType): Promise<void>;
12
12
  revokePermission(userId: string, targetId: string, type: PermissionType): Promise<void>;
13
+ private publishPermissionEvent;
13
14
  private revokeBranchPermissionsForCompany;
14
15
  findUserCompanies: (userId: string) => Promise<UserCompanyPermission[]>;
15
16
  findUserBranches(userId: string, companyId?: string): Promise<UserCompanyPermission[]>;