Refactor privilege management to support hierarchical privilege keys

- Updated privilege key structure to use a 3- or 4-part dotted hierarchy (e.g., `GROUP.PARENT.MODULE`).
- Modified the `RequirePrivilege` decorator to accept multiple keys, allowing for OR logic in privilege checks.
- Enhanced `PrivilegesGuard` to validate against multiple privilege keys, improving access control logic.
- Created migration scripts to update existing privilege keys in the database to the new format.
- Updated related services, controllers, and tests to accommodate the new privilege key structure and validation logic.
This commit is contained in:
shancheas
2026-09-01 13:14:58 +07:00
parent 365a37b8d2
commit 51db4f4a4d
45 changed files with 466 additions and 162 deletions
+3 -3
View File
@@ -11,7 +11,7 @@ alwaysApply: false
Every **non-public** controller handler on a primary (CRUD) resource MUST use:
```typescript
@RequirePrivilege('MODULE.RESOURCE', 'view' | 'create' | 'update' | 'delete' | 'import')
@RequirePrivilege('GROUP.PARENT.MODULE' | ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'], 'view' | 'create' | 'update' | 'delete' | 'import')
```
Map HTTP verbs to actions:
@@ -24,13 +24,13 @@ Map HTTP verbs to actions:
| `DELETE /:id`, bulk-delete | `delete` |
| `POST /import` | `import` |
Key codes use dotted uppercase module levels (`PRIVILEGES`, `SALES.INVOICE`). New modules add a `privilege_keys` seed row via migration — do not invent a parallel permission helper.
Key codes use 3- or 4-part dotted uppercase hierarchy: `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SALES.ACTIVITIES.INVOICE`, `MOBILE.SALES.PLAN`). Pass a string or string array to `@RequirePrivilege`; arrays use OR semantics. New modules add `privilege_keys` rows via migration — do not invent a parallel permission helper.
Seed an Administrator privilege only via SQL/ops after the first user exists (`created_by` requires a user). Documented bootstrap: insert privilege + details, then `UPDATE users SET privilege_id = …`. Do not auto-grant on register.
## Guard behavior
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the users assigned privilege must be **status `active`** and the matrix cell must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
`PrivilegesGuard` (global) allows when there is no metadata. When metadata is present, `users.is_superadmin === true` skips the matrix check. Otherwise the users assigned privilege must be **status `active`** and at least one matrix cell in the required key list must be `value === true`, or the request is `403 Forbidden`. Missing privilege / draft / archived / missing cell / `false` → deny.
Do not set `is_superadmin` via register/login. Default is `false`; promote via SQL/ops (`UPDATE users SET is_superadmin = true`). The flag is loaded from the database on each JWT validation (not from JWT claims).
@@ -0,0 +1,51 @@
-- Rename existing privilege_keys to Group.Parent.Module[.Submodule] hierarchy
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.PRIVILEGES', "label" = 'Privileges', "sort_order" = 101 WHERE "code" = 'PRIVILEGES';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.USER.USERS', "label" = 'Users', "sort_order" = 102 WHERE "code" = 'USERS';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.DIVISION', "label" = 'Divisions', "sort_order" = 103 WHERE "code" = 'CONFIGURATION.DIVISION';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.BRANCH', "label" = 'Branches', "sort_order" = 104 WHERE "code" = 'CONFIGURATION.BRANCH';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.CUSTOMER', "label" = 'Customers', "sort_order" = 105 WHERE "code" = 'CONFIGURATION.CUSTOMER';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.PRODUCT', "label" = 'Products', "sort_order" = 106 WHERE "code" = 'CONFIGURATION.PRODUCT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SETTINGS.DATA.SETTING', "label" = 'Company settings', "sort_order" = 107 WHERE "code" = 'CONFIGURATION.SETTING';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.EMPLOYEE', "label" = 'Employees', "sort_order" = 111 WHERE "code" = 'CONFIGURATION.EMPLOYEE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.DATA.CYCLE', "label" = 'Sales cycles', "sort_order" = 112 WHERE "code" = 'SALES.CYCLE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.REQUEST', "label" = 'Sales requests', "sort_order" = 113 WHERE "code" = 'SALES.REQUEST';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.ORDER', "label" = 'Sales orders', "sort_order" = 114 WHERE "code" = 'SALES.ORDER';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.INVOICE', "label" = 'Sales invoices', "sort_order" = 115 WHERE "code" = 'SALES.INVOICE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PAYMENT', "label" = 'Sales payments', "sort_order" = 116 WHERE "code" = 'SALES.PAYMENT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.ACTIVITIES.PLAN', "label" = 'Sales plans', "sort_order" = 117 WHERE "code" = 'SALES.PLAN';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.SALES.REPORT', "label" = 'Sales reports', "sort_order" = 118 WHERE "code" = 'SALES.REPORT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP', "label" = 'Packing slips', "sort_order" = 121 WHERE "code" = 'SALES.PACKING_SLIP';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.DATA.CYCLE', "label" = 'Logistics cycles', "sort_order" = 122 WHERE "code" = 'LOGISTICS.CYCLE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.ACTIVITIES.PLAN', "label" = 'Logistics plans', "sort_order" = 123 WHERE "code" = 'LOGISTICS.PLAN';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'ADMIN.LOGISTICS.REPORT', "label" = 'Logistics reports', "sort_order" = 124 WHERE "code" = 'LOGISTICS.REPORT';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.PLAN.ATTENDANCE', "label" = 'Branch attendance', "sort_order" = 201 WHERE "code" = 'FIELD.ATTENDANCE';
--> statement-breakpoint
UPDATE "privilege_keys" SET "code" = 'MOBILE.SALES.VISIT', "label" = 'Customer visits', "sort_order" = 202 WHERE "code" = 'FIELD.VISIT';
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('MOBILE.SALES.PLAN', 'Sales plans (mobile)', 203),
('MOBILE.SALES.REQUEST', 'Sales requests (mobile)', 204),
('MOBILE.SALES.ORDER', 'Sales orders (mobile)', 205),
('MOBILE.SALES.INVOICE', 'Sales invoices (mobile)', 206),
('MOBILE.SALES.PAYMENT', 'Sales payments (mobile)', 207),
('MOBILE.SALES.CUSTOMER', 'Customers (mobile)', 208),
('MOBILE.LOGISTICS.PLAN', 'Logistics plans (mobile)', 209);
+14
View File
@@ -106,6 +106,20 @@
"when": 1787562000000,
"tag": "0014_sales_invoice_location",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787563000000,
"tag": "0015_field_check_in",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1787564000000,
"tag": "0016_privilege_key_hierarchy",
"breakpoints": true
}
]
}
@@ -4,13 +4,22 @@ import type { PrivilegeAction } from '../../modules/privileges/privilege-action'
export const REQUIRE_PRIVILEGE_KEY = 'requirePrivilege';
export type RequirePrivilegeMeta = {
readonly key: string;
readonly keys: readonly string[];
readonly action: PrivilegeAction;
};
/** Marks a handler as requiring a privilege matrix cell to be true. */
export const RequirePrivilege = (key: string, action: PrivilegeAction) =>
function normalizePrivilegeKeys(
keys: string | readonly string[],
): readonly string[] {
return typeof keys === 'string' ? [keys] : keys;
}
/** Marks a handler as requiring one or more privilege matrix cells (OR). */
export const RequirePrivilege = (
keys: string | readonly string[],
action: PrivilegeAction,
) =>
SetMetadata(REQUIRE_PRIVILEGE_KEY, {
key,
keys: normalizePrivilegeKeys(keys),
action,
} satisfies RequirePrivilegeMeta);
+41 -13
View File
@@ -12,14 +12,14 @@ import {
import { PrivilegesGuard } from './privileges.guard';
describe('PrivilegesGuard', () => {
const checkPermission = jest.fn();
const checkAnyPermission = jest.fn();
const getAllAndOverride = jest.fn();
const reflector = {
getAllAndOverride,
} as unknown as Reflector;
const guard = new PrivilegesGuard(reflector, {
checkPermission,
checkAnyPermission,
} as never);
const user: AuthUser = {
@@ -46,26 +46,48 @@ describe('PrivilegesGuard', () => {
it('allows when no RequirePrivilege metadata', async () => {
getAllAndOverride.mockReturnValue(undefined);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('allows when permission value is true', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
it('allows when permission value is true for a single key', async () => {
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockResolvedValue(true);
checkAnyPermission.mockResolvedValue(true);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).toHaveBeenCalledWith(
expect(checkAnyPermission).toHaveBeenCalledWith(
'user-1',
'PRIVILEGES',
['ADMIN.SETTINGS.USER.PRIVILEGES'],
'view',
);
});
it('allows when any key in the list is granted', async () => {
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkAnyPermission.mockResolvedValue(true);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkAnyPermission).toHaveBeenCalledWith(
'user-1',
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
'view',
);
});
it('forbids when permission is false or missing', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'delete',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockResolvedValue(false);
checkAnyPermission.mockResolvedValue(false);
await expect(guard.canActivate(createContext(user))).rejects.toBeInstanceOf(
ForbiddenException,
@@ -73,17 +95,23 @@ describe('PrivilegesGuard', () => {
});
it('skips privilege lookup when user is superadmin', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'delete' };
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'delete',
};
getAllAndOverride.mockReturnValue(meta);
await expect(
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('unauthorized when metadata present but no user', async () => {
const meta: RequirePrivilegeMeta = { key: 'PRIVILEGES', action: 'view' };
const meta: RequirePrivilegeMeta = {
keys: ['ADMIN.SETTINGS.USER.PRIVILEGES'],
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
await expect(guard.canActivate(createContext())).rejects.toBeInstanceOf(
+2 -2
View File
@@ -39,9 +39,9 @@ export class PrivilegesGuard implements CanActivate {
return true;
}
const allowed = await this.privilegesService.checkPermission(
const allowed = await this.privilegesService.checkAnyPermission(
user.id,
required.key,
required.keys,
required.action,
);
if (!allowed) {
+2 -2
View File
@@ -118,7 +118,7 @@ describe('AuthController', () => {
status: 'active',
});
privilegesService.getPermissionsMap.mockResolvedValue({
PRIVILEGES: {
'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true,
create: true,
update: true,
@@ -137,7 +137,7 @@ describe('AuthController', () => {
).resolves.toMatchObject({
privilege: { id: 'priv-1', code: 'ADMIN' },
permissions: {
PRIVILEGES: expect.objectContaining({ view: true }),
'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ view: true }),
},
});
});
+1 -1
View File
@@ -123,7 +123,7 @@ export class MeResponseDto {
@ApiProperty({
description: 'Permission matrix keyed by privilege key code',
example: {
PRIVILEGES: {
'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true,
create: false,
update: false,
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { BranchDto, ListBranchesQueryDto } from './dto/branch.dto';
import { BranchesService } from './branches.service';
export const BRANCH_PRIVILEGE_KEY = 'CONFIGURATION.BRANCH';
export const BRANCH_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.BRANCH';
@ApiTags('branches')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -18,7 +18,10 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { CustomerDto, ListCustomersQueryDto } from './dto/customer.dto';
import { CustomersService } from './customers.service';
export const CUSTOMER_PRIVILEGE_KEY = 'CONFIGURATION.CUSTOMER';
export const CUSTOMER_PRIVILEGE_KEYS = [
'ADMIN.SETTINGS.DATA.CUSTOMER',
'MOBILE.SALES.CUSTOMER',
] as const;
@ApiTags('customers')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -28,7 +31,7 @@ export class CustomersReadController {
@Get()
@Pagination()
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List customers' })
@ApiOkResponse({
schema: {
@@ -50,7 +53,7 @@ export class CustomersReadController {
}
@Get(':id')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'view')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get customer detail' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from './customer-fields';
import { CUSTOMER_PRIVILEGE_KEY } from './customers-read.controller';
import { CUSTOMER_PRIVILEGE_KEYS } from './customers-read.controller';
import { CustomersService } from './customers.service';
import {
BulkIdsDto,
@@ -49,7 +49,7 @@ export class CustomersWriteController {
constructor(private readonly customersService: CustomersService) {}
@Post('import')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'import')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -88,7 +88,7 @@ export class CustomersWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete customers' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -101,7 +101,7 @@ export class CustomersWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update customer status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -116,7 +116,7 @@ export class CustomersWriteController {
}
@Post()
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'create')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create customer' })
@ApiCreatedResponse({ type: CustomerDto })
@ApiUnauthorizedResponse()
@@ -132,7 +132,7 @@ export class CustomersWriteController {
}
@Post(':id/contacts')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Add a customer contact' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -147,7 +147,7 @@ export class CustomersWriteController {
}
@Patch(':id/contacts/:contactId')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update a customer contact' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -167,7 +167,7 @@ export class CustomersWriteController {
@Delete(':id/contacts/:contactId')
@HttpCode(204)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Delete a customer contact' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -181,7 +181,7 @@ export class CustomersWriteController {
}
@Patch(':id/status')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update customer status' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -196,7 +196,7 @@ export class CustomersWriteController {
}
@Patch(':id')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update customer (not status)' })
@ApiOkResponse({ type: CustomerDto })
@ApiNotFoundResponse()
@@ -215,7 +215,7 @@ export class CustomersWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(CUSTOMER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete customer' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { DivisionDto, ListDivisionsQueryDto } from './dto/division.dto';
import { DivisionsService } from './divisions.service';
export const DIVISION_PRIVILEGE_KEY = 'CONFIGURATION.DIVISION';
export const DIVISION_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.DIVISION';
@ApiTags('divisions')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { EmployeeDto, ListEmployeesQueryDto } from './dto/employee.dto';
import { EmployeesService } from './employees.service';
export const EMPLOYEE_PRIVILEGE_KEY = 'CONFIGURATION.EMPLOYEE';
export const EMPLOYEE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.EMPLOYEE';
@ApiTags('employees')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { ProductDto, ListProductsQueryDto } from './dto/product.dto';
import { ProductsService } from './products.service';
export const PRODUCT_PRIVILEGE_KEY = 'CONFIGURATION.PRODUCT';
export const PRODUCT_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.PRODUCT';
@ApiTags('products')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -34,7 +34,7 @@ describe('CyclesService', () => {
const employeesService = { findById: jest.fn(), findByCode: jest.fn() };
const branchesService = { findById: jest.fn(), findByCode: jest.fn() };
const customersService = { findById: jest.fn(), findByCode: jest.fn() };
const privilegesService = { checkPermission: jest.fn() };
const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() };
const now = DateTime.fromUnixMs(1_700_000_000_000);
const user: AuthUser = {
+5 -5
View File
@@ -26,7 +26,7 @@ import {
type WeekdaysInput,
} from '../shared/field-fields';
import {
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
WEEKDAY_NAMES,
type FieldPurpose,
@@ -485,9 +485,9 @@ export class CyclesService {
}
const allowed: FieldPurpose[] = [];
for (const purpose of ['sales', 'logistics'] as const) {
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('cycle', purpose),
fieldPrivilegeKeys('cycle', purpose),
action,
);
if (ok) {
@@ -505,9 +505,9 @@ export class CyclesService {
if (user.isSuperadmin) {
return;
}
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('cycle', purpose),
fieldPrivilegeKeys('cycle', purpose),
action,
);
if (!ok) {
@@ -72,7 +72,7 @@ describe('PlansService', () => {
markDraftsProcessed: jest.fn(),
};
const packingSlipsService = { findById: jest.fn() };
const privilegesService = { checkPermission: jest.fn() };
const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn() };
const user: AuthUser = {
id: 'user-1',
+5 -5
View File
@@ -27,7 +27,7 @@ import { CyclesRepository } from '../cycles/cycles.repository';
import { CompanySettingsService } from '../settings/company-settings.service';
import {
cycleNumberForDate,
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
noCycleMessage,
type FieldPurpose,
@@ -615,9 +615,9 @@ export class PlansService {
}
const allowed: FieldPurpose[] = [];
for (const purpose of ['sales', 'logistics'] as const) {
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('plan', purpose),
fieldPrivilegeKeys('plan', purpose),
action,
);
if (ok) {
@@ -635,9 +635,9 @@ export class PlansService {
if (user.isSuperadmin) {
return;
}
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
user.id,
fieldPrivilegeKey('plan', purpose),
fieldPrivilegeKeys('plan', purpose),
action,
);
if (!ok) {
@@ -10,14 +10,14 @@ import { REQUIRE_FIELD_PRIVILEGE_KEY } from './field-privilege.decorator';
import { FieldPrivilegeGuard } from './field-privilege.guard';
describe('FieldPrivilegeGuard', () => {
const checkPermission = jest.fn();
const checkAnyPermission = jest.fn();
const getAllAndOverride = jest.fn();
const reflector = {
getAllAndOverride,
} as unknown as Reflector;
const guard = new FieldPrivilegeGuard(reflector, {
checkPermission,
checkAnyPermission,
} as never);
const user: AuthUser = {
@@ -48,7 +48,7 @@ describe('FieldPrivilegeGuard', () => {
it('allows when no field privilege metadata', async () => {
getAllAndOverride.mockReturnValue(undefined);
await expect(guard.canActivate(createContext(user))).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('allows sales purpose when sales key is granted', async () => {
@@ -57,8 +57,9 @@ describe('FieldPrivilegeGuard', () => {
action: 'create',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'SALES.CYCLE'),
checkAnyPermission.mockImplementation(
(_id: string, keys: readonly string[]) =>
Promise.resolve(keys.includes('ADMIN.SALES.DATA.CYCLE')),
);
await expect(
@@ -72,8 +73,9 @@ describe('FieldPrivilegeGuard', () => {
action: 'update',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'SALES.PLAN'),
checkAnyPermission.mockImplementation(
(_id: string, keys: readonly string[]) =>
Promise.resolve(keys.includes('MOBILE.SALES.PLAN')),
);
await expect(
@@ -87,8 +89,9 @@ describe('FieldPrivilegeGuard', () => {
action: 'view',
};
getAllAndOverride.mockReturnValue(meta);
checkPermission.mockImplementation((_id: string, key: string) =>
Promise.resolve(key === 'LOGISTICS.CYCLE'),
checkAnyPermission.mockImplementation(
(_id: string, keys: readonly string[]) =>
Promise.resolve(keys.includes('ADMIN.LOGISTICS.DATA.CYCLE')),
);
await expect(guard.canActivate(createContext(user, {}, {}))).resolves.toBe(
@@ -106,7 +109,7 @@ describe('FieldPrivilegeGuard', () => {
await expect(
guard.canActivate(createContext({ ...user, isSuperadmin: true })),
).resolves.toBe(true);
expect(checkPermission).not.toHaveBeenCalled();
expect(checkAnyPermission).not.toHaveBeenCalled();
});
it('unauthorized when metadata present but no user', async () => {
@@ -10,7 +10,7 @@ import type { AuthUser } from '../../../common/auth/auth-user';
import type { PrivilegeAction } from '../../privileges/privilege-action';
import { PrivilegesService } from '../../privileges/privileges.service';
import {
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
type FieldPurpose,
type FieldResource,
@@ -78,9 +78,9 @@ export class FieldPrivilegeGuard implements CanActivate {
const purposes: FieldPurpose[] = ['sales', 'logistics'];
const matches: FieldPurpose[] = [];
for (const purpose of purposes) {
const ok = await this.privilegesService.checkPermission(
const ok = await this.privilegesService.checkAnyPermission(
userId,
fieldPrivilegeKey(resource, purpose),
fieldPrivilegeKeys(resource, purpose),
action,
);
if (ok) {
@@ -1,6 +1,6 @@
import {
cycleNumberForDate,
fieldPrivilegeKey,
fieldPrivilegeKeys,
isFieldPurpose,
isWeekdayName,
noCycleMessage,
@@ -19,8 +19,13 @@ describe('field purpose helpers', () => {
});
it('maps resource and purpose to privilege keys', () => {
expect(fieldPrivilegeKey('cycle', 'sales')).toBe('SALES.CYCLE');
expect(fieldPrivilegeKey('plan', 'logistics')).toBe('LOGISTICS.PLAN');
expect(fieldPrivilegeKeys('cycle', 'sales')).toEqual([
'ADMIN.SALES.DATA.CYCLE',
]);
expect(fieldPrivilegeKeys('plan', 'logistics')).toEqual([
'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
'MOBILE.LOGISTICS.PLAN',
]);
});
it('returns purpose-specific missing-cycle messages', () => {
+16 -13
View File
@@ -22,28 +22,31 @@ export function isWeekdayName(raw: string): raw is WeekdayName {
return (WEEKDAY_NAMES as readonly string[]).includes(raw);
}
export const SALES_CYCLE_PRIVILEGE_KEY = 'SALES.CYCLE';
export const SALES_PLAN_PRIVILEGE_KEY = 'SALES.PLAN';
export const LOGISTICS_CYCLE_PRIVILEGE_KEY = 'LOGISTICS.CYCLE';
export const LOGISTICS_PLAN_PRIVILEGE_KEY = 'LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'CONFIGURATION.SETTING';
export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'FIELD.ATTENDANCE';
export const FIELD_VISIT_PRIVILEGE_KEY = 'FIELD.VISIT';
export const ADMIN_SALES_CYCLE_PRIVILEGE_KEY = 'ADMIN.SALES.DATA.CYCLE';
export const ADMIN_SALES_PLAN_PRIVILEGE_KEY = 'ADMIN.SALES.ACTIVITIES.PLAN';
export const ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.DATA.CYCLE';
export const ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY =
'ADMIN.LOGISTICS.ACTIVITIES.PLAN';
export const MOBILE_SALES_PLAN_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN';
export const MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY = 'MOBILE.LOGISTICS.PLAN';
export const SETTINGS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.DATA.SETTING';
export const FIELD_ATTENDANCE_PRIVILEGE_KEY = 'MOBILE.SALES.PLAN.ATTENDANCE';
export const FIELD_VISIT_PRIVILEGE_KEY = 'MOBILE.SALES.VISIT';
export type FieldResource = 'cycle' | 'plan';
export function fieldPrivilegeKey(
export function fieldPrivilegeKeys(
resource: FieldResource,
purpose: FieldPurpose,
): string {
): readonly string[] {
if (resource === 'cycle') {
return purpose === 'sales'
? SALES_CYCLE_PRIVILEGE_KEY
: LOGISTICS_CYCLE_PRIVILEGE_KEY;
? [ADMIN_SALES_CYCLE_PRIVILEGE_KEY]
: [ADMIN_LOGISTICS_CYCLE_PRIVILEGE_KEY];
}
return purpose === 'sales'
? SALES_PLAN_PRIVILEGE_KEY
: LOGISTICS_PLAN_PRIVILEGE_KEY;
? [ADMIN_SALES_PLAN_PRIVILEGE_KEY, MOBILE_SALES_PLAN_PRIVILEGE_KEY]
: [ADMIN_LOGISTICS_PLAN_PRIVILEGE_KEY, MOBILE_LOGISTICS_PLAN_PRIVILEGE_KEY];
}
export function noCycleMessage(purpose: FieldPurpose): string {
+2 -2
View File
@@ -146,7 +146,7 @@ export class PrivilegeDetailDto {
@ApiProperty({ format: 'uuid' })
privilegeKeyId!: string;
@ApiProperty({ example: 'SALES.INVOICE' })
@ApiProperty({ example: 'ADMIN.SALES.ACTIVITIES.INVOICE' })
keyCode!: string;
@ApiProperty({ example: 'Sales Invoice' })
@@ -197,7 +197,7 @@ export class PrivilegeKeyDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ example: 'PRIVILEGES' })
@ApiProperty({ example: 'ADMIN.SETTINGS.USER.PRIVILEGES' })
code!: string;
@ApiProperty({ example: 'Privileges' })
@@ -17,14 +17,15 @@ describe('privilege-action', () => {
});
describe('privilege-key-code', () => {
it('accepts dotted uppercase module levels', () => {
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(true);
expect(isValidPrivilegeKeyCode('SALES.INVOICE')).toBe(true);
expect(isValidPrivilegeKeyCode('SALES.INVOICE.LINE')).toBe(true);
expect(assertPrivilegeKeyCode('USERS')).toBe('USERS');
it('accepts 3- and 4-part dotted uppercase codes', () => {
expect(isValidPrivilegeKeyCode('ADMIN.SETTINGS.USER.PRIVILEGES')).toBe(true);
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.INVOICE')).toBe(true);
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN');
});
it('rejects invalid codes', () => {
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
expect(isValidPrivilegeKeyCode('sales.invoice')).toBe(false);
expect(isValidPrivilegeKeyCode('SALES.')).toBe(false);
expect(isValidPrivilegeKeyCode('.SALES')).toBe(false);
@@ -0,0 +1,67 @@
import {
assertPrivilegeKeyCode,
isValidPrivilegeKeyCode,
parsePrivilegeKeyCode,
} from './privilege-key-code';
describe('privilege-key-code', () => {
describe('isValidPrivilegeKeyCode', () => {
it('accepts 3-part keys', () => {
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe(true);
expect(isValidPrivilegeKeyCode('ADMIN.SALES.REPORT')).toBe(true);
});
it('accepts 4-part keys', () => {
expect(isValidPrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toBe(true);
expect(isValidPrivilegeKeyCode('ADMIN.SALES.ACTIVITIES.PLAN')).toBe(true);
});
it('rejects fewer than 3 segments', () => {
expect(isValidPrivilegeKeyCode('PRIVILEGES')).toBe(false);
expect(isValidPrivilegeKeyCode('SALES.PLAN')).toBe(false);
});
it('rejects more than 4 segments', () => {
expect(isValidPrivilegeKeyCode('A.B.C.D.E')).toBe(false);
});
it('rejects invalid characters', () => {
expect(isValidPrivilegeKeyCode('mobile.sales.plan')).toBe(false);
expect(isValidPrivilegeKeyCode('MOBILE..PLAN')).toBe(false);
});
});
describe('assertPrivilegeKeyCode', () => {
it('returns the code when valid', () => {
expect(assertPrivilegeKeyCode('MOBILE.SALES.PLAN')).toBe('MOBILE.SALES.PLAN');
});
it('throws when invalid', () => {
expect(() => assertPrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
});
});
describe('parsePrivilegeKeyCode', () => {
it('parses a 3-part key with null submodule', () => {
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: null,
});
});
it('parses a 4-part key', () => {
expect(parsePrivilegeKeyCode('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: 'ATTENDANCE',
});
});
it('throws for invalid codes', () => {
expect(() => parsePrivilegeKeyCode('PRIVILEGES')).toThrow(TypeError);
});
});
});
+20 -2
View File
@@ -1,6 +1,13 @@
/** Dotted uppercase module levels: MODULE / MODULE.RESOURCE / MODULE.RESOURCE.SUB */
/** Dotted uppercase: Group.Parent.Module or Group.Parent.Module.Submodule */
export const PRIVILEGE_KEY_CODE_PATTERN =
/^[A-Z][A-Z0-9_]*(\.[A-Z][A-Z0-9_]*)*$/;
/^[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*\.[A-Z][A-Z0-9_]*(?:\.[A-Z][A-Z0-9_]*)?$/;
export type ParsedPrivilegeKeyCode = {
readonly group: string;
readonly parent: string;
readonly module: string;
readonly submodule: string | null;
};
export function isValidPrivilegeKeyCode(code: string): boolean {
return typeof code === 'string' && PRIVILEGE_KEY_CODE_PATTERN.test(code);
@@ -12,3 +19,14 @@ export function assertPrivilegeKeyCode(code: string): string {
}
return code;
}
export function parsePrivilegeKeyCode(code: string): ParsedPrivilegeKeyCode {
assertPrivilegeKeyCode(code);
const parts = code.split('.');
if (parts.length === 3) {
const [group, parent, module] = parts;
return { group, parent, module, submodule: null };
}
const [group, parent, module, submodule] = parts;
return { group, parent, module, submodule };
}
@@ -28,7 +28,7 @@ export class PrivilegeKeysController {
@Get()
@Pagination()
@RequirePrivilege('PRIVILEGES', 'view')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
@ApiOperation({ summary: 'List privilege keys catalog' })
@ApiOkResponse({
schema: {
@@ -30,7 +30,7 @@ export class PrivilegesReadController {
@Get()
@Pagination()
@RequirePrivilege('PRIVILEGES', 'view')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
@ApiOperation({ summary: 'List privileges' })
@ApiOkResponse({
schema: {
@@ -52,7 +52,7 @@ export class PrivilegesReadController {
}
@Get(':id')
@RequirePrivilege('PRIVILEGES', 'view')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'view')
@ApiOperation({ summary: 'Get privilege detail with matrix' })
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
@ApiNotFoundResponse()
@@ -46,7 +46,7 @@ export class PrivilegesWriteController {
constructor(private readonly privilegesService: PrivilegesService) {}
@Post('import')
@RequirePrivilege('PRIVILEGES', 'import')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -90,7 +90,7 @@ export class PrivilegesWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege('PRIVILEGES', 'delete')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
@ApiOperation({ summary: 'Bulk delete privileges' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -103,7 +103,7 @@ export class PrivilegesWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege('PRIVILEGES', 'update')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
@ApiOperation({ summary: 'Bulk update privilege status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -118,7 +118,7 @@ export class PrivilegesWriteController {
}
@Post()
@RequirePrivilege('PRIVILEGES', 'create')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'create')
@ApiOperation({ summary: 'Create privilege' })
@ApiCreatedResponse({ type: PrivilegeDetailResponseDto })
@ApiUnauthorizedResponse()
@@ -137,7 +137,7 @@ export class PrivilegesWriteController {
}
@Patch(':id/status')
@RequirePrivilege('PRIVILEGES', 'update')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
@ApiOperation({ summary: 'Update privilege status' })
@ApiOkResponse({ type: PrivilegeDto })
@ApiNotFoundResponse()
@@ -152,7 +152,7 @@ export class PrivilegesWriteController {
}
@Patch(':id')
@RequirePrivilege('PRIVILEGES', 'update')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'update')
@ApiOperation({ summary: 'Update privilege (not status)' })
@ApiOkResponse({ type: PrivilegeDetailResponseDto })
@ApiNotFoundResponse()
@@ -173,7 +173,7 @@ export class PrivilegesWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege('PRIVILEGES', 'delete')
@RequirePrivilege('ADMIN.SETTINGS.USER.PRIVILEGES', 'delete')
@ApiOperation({ summary: 'Delete privilege' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -389,6 +389,39 @@ export class PrivilegesRepository {
return row?.value === true;
}
async checkAnyPermission(
userId: string,
keyCodes: readonly string[],
action: PrivilegeAction,
): Promise<boolean> {
if (keyCodes.length === 0) {
return false;
}
const [row] = await this.db
.select({ value: privilegeDetails.value })
.from(users)
.innerJoin(privileges, eq(users.privilegeId, privileges.id))
.innerJoin(
privilegeDetails,
eq(privilegeDetails.privilegeId, privileges.id),
)
.innerJoin(
privilegeKeys,
eq(privilegeDetails.privilegeKeyId, privilegeKeys.id),
)
.where(
and(
eq(users.id, userId),
eq(privileges.status, 'active'),
inArray(privilegeKeys.code, [...keyCodes]),
eq(privilegeDetails.action, action),
eq(privilegeDetails.value, true),
),
)
.limit(1);
return row?.value === true;
}
async getPermissionsMap(
privilegeId: string,
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
@@ -23,6 +23,7 @@ describe('PrivilegesService', () => {
| 'listKeys'
| 'findKeyById'
| 'checkPermission'
| 'checkAnyPermission'
| 'getPermissionsMap'
>
>;
@@ -56,6 +57,7 @@ describe('PrivilegesService', () => {
listKeys: jest.fn(),
findKeyById: jest.fn(),
checkPermission: jest.fn(),
checkAnyPermission: jest.fn(),
getPermissionsMap: jest.fn(),
};
@@ -140,7 +142,44 @@ describe('PrivilegesService', () => {
it('checkPermission delegates', async () => {
repository.checkPermission.mockResolvedValue(true);
await expect(
service.checkPermission('user-1', 'PRIVILEGES', 'view'),
service.checkPermission(
'user-1',
'ADMIN.SETTINGS.USER.PRIVILEGES',
'view',
),
).resolves.toBe(true);
});
it('checkAnyPermission delegates for multiple keys', async () => {
repository.checkAnyPermission.mockResolvedValue(true);
await expect(
service.checkAnyPermission(
'user-1',
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
'view',
),
).resolves.toBe(true);
expect(repository.checkAnyPermission).toHaveBeenCalledWith(
'user-1',
['ADMIN.SALES.ACTIVITIES.PLAN', 'MOBILE.SALES.PLAN'],
'view',
);
});
it('checkAnyPermission uses checkPermission for a single key', async () => {
repository.checkPermission.mockResolvedValue(true);
await expect(
service.checkAnyPermission(
'user-1',
['MOBILE.SALES.PLAN'],
'view',
),
).resolves.toBe(true);
expect(repository.checkPermission).toHaveBeenCalledWith(
'user-1',
'MOBILE.SALES.PLAN',
'view',
);
expect(repository.checkAnyPermission).not.toHaveBeenCalled();
});
});
@@ -262,6 +262,24 @@ export class PrivilegesService {
return this.privilegesRepository.checkPermission(userId, keyCode, action);
}
async checkAnyPermission(
userId: string,
keyCodes: readonly string[],
action: PrivilegeAction,
): Promise<boolean> {
if (keyCodes.length === 0) {
return false;
}
if (keyCodes.length === 1) {
return this.checkPermission(userId, keyCodes[0], action);
}
return this.privilegesRepository.checkAnyPermission(
userId,
keyCodes,
action,
);
}
async getPermissionsMap(
privilegeId: string,
): Promise<Record<string, Record<PrivilegeAction, boolean>>> {
@@ -12,7 +12,7 @@ const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
export const packingSlipReport: ReportConfigEntity = {
groupName: REPORT_GROUP.LOGISTICS_REPORT,
uniqueName: `${LOGISTICS_PREFIX}packing_slip`,
privilegeKey: 'LOGISTICS.REPORT',
privilegeKey: 'ADMIN.LOGISTICS.REPORT',
label: 'Report Packing Slip',
tableSchema: `packing_slips main
JOIN customers cust ON cust.id = main.customer_id`,
@@ -89,7 +89,7 @@ export const packingSlipReport: ReportConfigEntity = {
export const deliveryPlanReport: ReportConfigEntity = {
groupName: REPORT_GROUP.LOGISTICS_REPORT,
uniqueName: `${LOGISTICS_PREFIX}delivery_plan`,
privilegeKey: 'LOGISTICS.REPORT',
privilegeKey: 'ADMIN.LOGISTICS.REPORT',
label: 'Report Delivery Plan',
tableSchema: `plans main
JOIN employees emp ON emp.id = main.employee_id
@@ -13,7 +13,7 @@ const LOGISTICS_PREFIX = `${REPORT_GROUP.LOGISTICS_REPORT}__`;
export const salesOrderReport: ReportConfigEntity = {
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName: `${SALES_PREFIX}sales_order`,
privilegeKey: 'SALES.REPORT',
privilegeKey: 'ADMIN.SALES.REPORT',
label: 'Report Sales Order',
tableSchema: `sales_orders main
JOIN customers cust ON cust.id = main.customer_id
@@ -138,7 +138,7 @@ export const salesOrderReport: ReportConfigEntity = {
export const salesRequestReport: ReportConfigEntity = {
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName: `${SALES_PREFIX}sales_request`,
privilegeKey: 'SALES.REPORT',
privilegeKey: 'ADMIN.SALES.REPORT',
label: 'Report Request Order',
tableSchema: `sales_requests main
JOIN customers cust ON cust.id = main.customer_id
@@ -250,7 +250,7 @@ export const salesRequestReport: ReportConfigEntity = {
export const salesInvoiceReport: ReportConfigEntity = {
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName: `${SALES_PREFIX}sales_invoice`,
privilegeKey: 'SALES.REPORT',
privilegeKey: 'ADMIN.SALES.REPORT',
label: 'Report Invoice',
tableSchema: `sales_invoices main
JOIN customers cust ON cust.id = main.customer_id
@@ -365,7 +365,7 @@ export const salesInvoiceReport: ReportConfigEntity = {
export const salesPaymentReport: ReportConfigEntity = {
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName: `${SALES_PREFIX}sales_payment`,
privilegeKey: 'SALES.REPORT',
privilegeKey: 'ADMIN.SALES.REPORT',
label: 'Report Payment',
tableSchema: `sales_payments main
JOIN sales_payment_invoices spi ON spi.sales_payment_id = main.id
@@ -476,7 +476,7 @@ export const salesPaymentReport: ReportConfigEntity = {
export const visitPlanReport: ReportConfigEntity = {
groupName: REPORT_GROUP.SALES_REPORT,
uniqueName: `${SALES_PREFIX}visit_plan`,
privilegeKey: 'SALES.REPORT',
privilegeKey: 'ADMIN.SALES.REPORT',
label: 'Report Visit Plan',
tableSchema: `plans main
JOIN employees emp ON emp.id = main.employee_id
@@ -21,7 +21,7 @@ import {
} from './dto/packing-slip.dto';
import { PackingSlipsService } from './packing-slips.service';
export const PACKING_SLIP_PRIVILEGE_KEY = 'SALES.PACKING_SLIP';
export const PACKING_SLIP_PRIVILEGE_KEY = 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP';
@ApiTags('packing-slips')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -21,7 +21,10 @@ import {
} from './dto/sales-invoice.dto';
import { SalesInvoicesService } from './sales-invoices.service';
export const SALES_INVOICE_PRIVILEGE_KEY = 'SALES.INVOICE';
export const SALES_INVOICE_PRIVILEGE_KEYS = [
'ADMIN.SALES.ACTIVITIES.INVOICE',
'MOBILE.SALES.INVOICE',
] as const;
@ApiTags('sales-invoices')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -31,7 +34,7 @@ export class SalesInvoicesReadController {
@Get()
@Pagination()
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List sales invoices' })
@ApiOkResponse({
schema: {
@@ -53,7 +56,7 @@ export class SalesInvoicesReadController {
}
@Get(':id')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get sales invoice detail' })
@ApiOkResponse({ type: SalesInvoiceDto })
@ApiNotFoundResponse()
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from '../shared/sales-fields';
import { SALES_INVOICE_PRIVILEGE_KEY } from './sales-invoices-read.controller';
import { SALES_INVOICE_PRIVILEGE_KEYS } from './sales-invoices-read.controller';
import { SalesInvoicesService } from './sales-invoices.service';
import {
BulkIdsDto,
@@ -47,7 +47,7 @@ export class SalesInvoicesWriteController {
constructor(private readonly salesInvoicesService: SalesInvoicesService) {}
@Post('import')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'import')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -86,7 +86,7 @@ export class SalesInvoicesWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete sales invoices' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -99,7 +99,7 @@ export class SalesInvoicesWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update sales invoice status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -118,7 +118,7 @@ export class SalesInvoicesWriteController {
}
@Post()
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'create')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create sales invoice' })
@ApiCreatedResponse({ type: SalesInvoiceDto })
@ApiUnauthorizedResponse()
@@ -147,7 +147,7 @@ export class SalesInvoicesWriteController {
}
@Patch(':id/status')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales invoice status' })
@ApiOkResponse({ type: SalesInvoiceDto })
@ApiNotFoundResponse()
@@ -162,7 +162,7 @@ export class SalesInvoicesWriteController {
}
@Patch(':id')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales invoice (not status)' })
@ApiOkResponse({ type: SalesInvoiceDto })
@ApiNotFoundResponse()
@@ -193,7 +193,7 @@ export class SalesInvoicesWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete sales invoice' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -18,7 +18,10 @@ import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { SalesOrderDto, ListSalesOrdersQueryDto } from './dto/sales-order.dto';
import { SalesOrdersService } from './sales-orders.service';
export const SALES_ORDER_PRIVILEGE_KEY = 'SALES.ORDER';
export const SALES_ORDER_PRIVILEGE_KEYS = [
'ADMIN.SALES.ACTIVITIES.ORDER',
'MOBILE.SALES.ORDER',
] as const;
@ApiTags('sales-orders')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -28,7 +31,7 @@ export class SalesOrdersReadController {
@Get()
@Pagination()
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List sales orders' })
@ApiOkResponse({
schema: {
@@ -50,7 +53,7 @@ export class SalesOrdersReadController {
}
@Get(':id')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get sales order detail' })
@ApiOkResponse({ type: SalesOrderDto })
@ApiNotFoundResponse()
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from '../shared/sales-fields';
import { SALES_ORDER_PRIVILEGE_KEY } from './sales-orders-read.controller';
import { SALES_ORDER_PRIVILEGE_KEYS } from './sales-orders-read.controller';
import { SalesOrdersService } from './sales-orders.service';
import {
BulkIdsDto,
@@ -47,7 +47,7 @@ export class SalesOrdersWriteController {
constructor(private readonly salesOrdersService: SalesOrdersService) {}
@Post('import')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'import')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -86,7 +86,7 @@ export class SalesOrdersWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete sales orders' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -99,7 +99,7 @@ export class SalesOrdersWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update sales order status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -119,7 +119,7 @@ export class SalesOrdersWriteController {
}
@Post()
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'create')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create sales order' })
@ApiCreatedResponse({ type: SalesOrderDto })
@ApiUnauthorizedResponse()
@@ -148,7 +148,7 @@ export class SalesOrdersWriteController {
}
@Patch(':id/status')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales order status' })
@ApiOkResponse({ type: SalesOrderDto })
@ApiNotFoundResponse()
@@ -165,7 +165,7 @@ export class SalesOrdersWriteController {
}
@Patch(':id')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales order (not status)' })
@ApiOkResponse({ type: SalesOrderDto })
@ApiNotFoundResponse()
@@ -195,7 +195,7 @@ export class SalesOrdersWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete sales order' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -21,7 +21,10 @@ import {
} from './dto/sales-payment.dto';
import { SalesPaymentsService } from './sales-payments.service';
export const SALES_PAYMENT_PRIVILEGE_KEY = 'SALES.PAYMENT';
export const SALES_PAYMENT_PRIVILEGE_KEYS = [
'ADMIN.SALES.ACTIVITIES.PAYMENT',
'MOBILE.SALES.PAYMENT',
] as const;
@ApiTags('sales-payments')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -31,7 +34,7 @@ export class SalesPaymentsReadController {
@Get()
@Pagination()
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List sales payments' })
@ApiOkResponse({
schema: {
@@ -53,7 +56,7 @@ export class SalesPaymentsReadController {
}
@Get(':id')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get sales payment detail' })
@ApiOkResponse({ type: SalesPaymentDto })
@ApiNotFoundResponse()
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from '../shared/sales-fields';
import { SALES_PAYMENT_PRIVILEGE_KEY } from './sales-payments-read.controller';
import { SALES_PAYMENT_PRIVILEGE_KEYS } from './sales-payments-read.controller';
import { SalesPaymentsService } from './sales-payments.service';
import {
BulkIdsDto,
@@ -47,7 +47,7 @@ export class SalesPaymentsWriteController {
constructor(private readonly salesPaymentsService: SalesPaymentsService) {}
@Post('import')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'import')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -86,7 +86,7 @@ export class SalesPaymentsWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete sales payments' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -99,7 +99,7 @@ export class SalesPaymentsWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update sales payment status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -118,7 +118,7 @@ export class SalesPaymentsWriteController {
}
@Post()
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'create')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create sales payment' })
@ApiCreatedResponse({ type: SalesPaymentDto })
@ApiUnauthorizedResponse()
@@ -139,7 +139,7 @@ export class SalesPaymentsWriteController {
}
@Patch(':id/status')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales payment status' })
@ApiOkResponse({ type: SalesPaymentDto })
@ApiNotFoundResponse()
@@ -154,7 +154,7 @@ export class SalesPaymentsWriteController {
}
@Patch(':id')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales payment (not status)' })
@ApiOkResponse({ type: SalesPaymentDto })
@ApiNotFoundResponse()
@@ -177,7 +177,7 @@ export class SalesPaymentsWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete sales payment' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
@@ -21,7 +21,10 @@ import {
} from './dto/sales-request.dto';
import { SalesRequestsService } from './sales-requests.service';
export const SALES_REQUEST_PRIVILEGE_KEY = 'SALES.REQUEST';
export const SALES_REQUEST_PRIVILEGE_KEYS = [
'ADMIN.SALES.ACTIVITIES.REQUEST',
'MOBILE.SALES.REQUEST',
] as const;
@ApiTags('sales-requests')
@ApiBearerAuth(BEARER_AUTH_NAME)
@@ -31,7 +34,7 @@ export class SalesRequestsReadController {
@Get()
@Pagination()
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'List sales requests' })
@ApiOkResponse({
schema: {
@@ -53,7 +56,7 @@ export class SalesRequestsReadController {
}
@Get(':id')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'view')
@ApiOperation({ summary: 'Get sales request detail' })
@ApiOkResponse({ type: SalesRequestDto })
@ApiNotFoundResponse()
@@ -29,7 +29,7 @@ import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
import { isAllowedCsvUpload } from '../shared/sales-fields';
import { SALES_REQUEST_PRIVILEGE_KEY } from './sales-requests-read.controller';
import { SALES_REQUEST_PRIVILEGE_KEYS } from './sales-requests-read.controller';
import { SalesRequestsService } from './sales-requests.service';
import {
BulkIdsDto,
@@ -47,7 +47,7 @@ export class SalesRequestsWriteController {
constructor(private readonly salesRequestsService: SalesRequestsService) {}
@Post('import')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'import')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'import')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 1_048_576 },
@@ -86,7 +86,7 @@ export class SalesRequestsWriteController {
@Post('bulk-delete')
@HttpCode(200)
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Bulk delete sales requests' })
@ApiOkResponse({
schema: { properties: { deleted: { type: 'number' } } },
@@ -99,7 +99,7 @@ export class SalesRequestsWriteController {
@Post('bulk-status')
@HttpCode(200)
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Bulk update sales request status' })
@ApiOkResponse({
schema: { properties: { updated: { type: 'number' } } },
@@ -118,7 +118,7 @@ export class SalesRequestsWriteController {
}
@Post()
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'create')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'create')
@ApiOperation({ summary: 'Create sales request' })
@ApiCreatedResponse({ type: SalesRequestDto })
@ApiUnauthorizedResponse()
@@ -146,7 +146,7 @@ export class SalesRequestsWriteController {
}
@Patch(':id/status')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales request status' })
@ApiOkResponse({ type: SalesRequestDto })
@ApiNotFoundResponse()
@@ -161,7 +161,7 @@ export class SalesRequestsWriteController {
}
@Patch(':id')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'update')
@ApiOperation({ summary: 'Update sales request (not status)' })
@ApiOkResponse({ type: SalesRequestDto })
@ApiNotFoundResponse()
@@ -191,7 +191,7 @@ export class SalesRequestsWriteController {
@Delete(':id')
@HttpCode(204)
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'delete')
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEYS, 'delete')
@ApiOperation({ summary: 'Delete sales request' })
@ApiNoContentResponse()
@ApiNotFoundResponse()
+1 -1
View File
@@ -18,7 +18,7 @@ import { BEARER_AUTH_NAME } from '../../common/swagger/setup-swagger';
import { ListUsersQueryDto, UserDto } from './dto/user.dto';
import { UsersService } from './users.service';
export const USERS_PRIVILEGE_KEY = 'USERS';
export const USERS_PRIVILEGE_KEY = 'ADMIN.SETTINGS.USER.USERS';
@ApiTags('users')
@ApiBearerAuth(BEARER_AUTH_NAME)
+5 -5
View File
@@ -115,8 +115,8 @@ describe('Privileges (e2e)', () => {
expect(res.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'PRIVILEGES' }),
expect.objectContaining({ code: 'USERS' }),
expect.objectContaining({ code: 'ADMIN.SETTINGS.USER.PRIVILEGES' }),
expect.objectContaining({ code: 'ADMIN.SETTINGS.USER.USERS' }),
]),
);
expect(res.body.meta).toMatchObject({
@@ -131,7 +131,7 @@ describe('Privileges (e2e)', () => {
.expect(200);
const privilegesKey = (
keysRes.body.data as { id: string; code: string }[]
).find((k) => k.code === 'PRIVILEGES');
).find((k) => k.code === 'ADMIN.SETTINGS.USER.PRIVILEGES');
expect(privilegesKey).toBeDefined();
const created = await request(app.getHttpServer())
@@ -156,7 +156,7 @@ describe('Privileges (e2e)', () => {
createdBy: adminUserId,
details: [
expect.objectContaining({
keyCode: 'PRIVILEGES',
keyCode: 'ADMIN.SETTINGS.USER.PRIVILEGES',
action: 'view',
value: true,
}),
@@ -202,7 +202,7 @@ describe('Privileges (e2e)', () => {
expect(me.body.privilege).toMatchObject({
id: adminPrivilegeId,
});
expect(me.body.permissions.PRIVILEGES.view).toBe(true);
expect(me.body.permissions['ADMIN.SETTINGS.USER.PRIVILEGES'].view).toBe(true);
await request(app.getHttpServer())
.patch(`/users/${otherUserId}/privilege`)