feat: update privilege keys and module configurations for enhanced access control

- Refactored privilege keys in `api.md` to use a more structured naming convention, aligning with the new `Group.Parent.Module` format.
- Updated various module configurations across the application to reflect the new privilege key structure, ensuring consistent access control.
- Removed deprecated keys and streamlined the privilege management process, enhancing clarity and maintainability.
- Added new tests for privilege key parsing and grouping functionalities to ensure reliability and correctness.

These changes significantly improve the application's privilege management system, providing a clearer structure for access control and enhancing overall security.
This commit is contained in:
shancheas
2026-09-01 13:15:12 +07:00
parent 05c8459d12
commit 6a54aa6c50
51 changed files with 592 additions and 225 deletions
+5 -5
View File
@@ -8,11 +8,11 @@ alwaysApply: false
Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path. Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path.
Catalog keys live in `api.md` §4 (`PRIVILEGES`, `CONFIGURATION.BRANCH`, `SALES.ORDER`, …). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`). Catalog keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`, `ADMIN.SALES.ACTIVITIES.ORDER`). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
## Required wiring (do all four) ## Required wiring (do all four)
1. **`moduleKey`** on `ModuleConfigEntity` equals the catalog `code` (e.g. `CONFIGURATION.BRANCH`). 1. **`moduleKey`** on `ModuleConfigEntity` equals the Admin catalog `code` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`).
2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false. 2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false.
3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash). 3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash).
4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`. 4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`.
@@ -23,8 +23,8 @@ if (!user.permissions.BRANCHES?.create) return null;
{ key: 'branches', path: '/app/system/branches/index' } { key: 'branches', path: '/app/system/branches/index' }
// GOOD // GOOD
export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ... */ }; export const branchesModuleConfig = { moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH', /* ... */ };
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'CONFIGURATION.BRANCH' } { key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH' }
``` ```
## Flag map (`mapUserPrivileges`) ## Flag map (`mapUserPrivileges`)
@@ -37,6 +37,6 @@ export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ...
| `delete` | `ALLOW_DELETE` | | `delete` | `ALLOW_DELETE` |
| `import` | `ALLOW_IMPORT` | | `import` | `ALLOW_IMPORT` |
Missing flag → `false`. Cycles/plans: key follows `purpose` (`SALES.CYCLE` / `LOGISTICS.PLAN`), not a generic `CYCLES` key. Missing flag → `false`. Cycles/plans: key follows `purpose` (`ADMIN.SALES.DATA.CYCLE` / `ADMIN.LOGISTICS.ACTIVITIES.PLAN`), not a generic `CYCLES` key.
Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts). Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts).
+28 -23
View File
@@ -240,36 +240,41 @@ HTTP mapping:
| `DELETE /:id`, `POST /bulk-delete` | `delete` | | `DELETE /:id`, `POST /bulk-delete` | `delete` |
| `POST /import` | `import` | | `POST /import` | `import` |
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`): Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
| code | label | | code | label |
| ------------------------ | ---------------- | | ---- | ----- |
| `PRIVILEGES` | Privileges | | `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
| `USERS` | Users | | `ADMIN.SETTINGS.USER.USERS` | Users |
| `CONFIGURATION.DIVISION` | Divisions | | `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
| `CONFIGURATION.BRANCH` | Branches | | `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
| `CONFIGURATION.CUSTOMER` | Customers | | `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
| `CONFIGURATION.EMPLOYEE` | Employees | | `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
| `CONFIGURATION.PRODUCT` | Products | | `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
| `SALES.REQUEST` | Sales requests | | `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
| `SALES.ORDER` | Sales orders | | `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
| `SALES.PACKING_SLIP` | Packing slips | | `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
| `SALES.INVOICE` | Sales invoices | | `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
| `SALES.PAYMENT` | Sales payments | | `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
| `CONFIGURATION.SETTING` | Company settings | | `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
| `SALES.CYCLE` | Sales cycles | | `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
| `SALES.PLAN` | Sales plans | | `ADMIN.SALES.REPORT` | Sales reports |
| `LOGISTICS.CYCLE` | Logistics cycles | | `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
| `LOGISTICS.PLAN` | Logistics plans | | `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
| `MOBILE.SALES.VISIT` | Customer visits |
### Field purpose ### Field purpose
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`: Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
| purpose | cycle key | plan key | | purpose | cycle keys | plan keys |
| ----------- | ----------------- | ---------------- | | ----------- | ---------- | --------- |
| `sales` | `SALES.CYCLE` | `SALES.PLAN` | | `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` | | `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.LOGISTICS.PLAN` |
`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses. `purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses.
-40
View File
@@ -167,9 +167,6 @@ export default function LoginPage() {
label: { color: '#64748b', fontWeight: 500 }, label: { color: '#64748b', fontWeight: 500 },
}} }}
/> />
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
{t('forgot_password')}
</a>
</div> </div>
<Button <Button
@@ -182,43 +179,6 @@ export default function LoginPage() {
> >
{t('login_button')} {t('login_button')}
</Button> </Button>
<Divider
label={t('or_login_with')}
labelPosition="center"
my="lg"
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
/>
<div className="grid grid-cols-3 gap-4">
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<GoogleIcon className="w-5 h-5" />
</Button>
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
</Button>
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<MicrosoftIcon className="w-5 h-5" />
</Button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -50,14 +50,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-employees', label: 'nav:configuration-employees',
icon: Users, icon: Users,
path: '/app/sales/employees/index', path: '/app/sales/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE', moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
}, },
{ {
key: 'sales-cycles', key: 'sales-cycles',
label: 'nav:sales-cycles', label: 'nav:sales-cycles',
icon: Repeat, icon: Repeat,
path: '/app/sales/cycles/index', path: '/app/sales/cycles/index',
moduleKey: 'SALES.CYCLE', moduleKey: 'ADMIN.SALES.DATA.CYCLE',
}, },
], ],
}, },
@@ -72,35 +72,35 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:sales-requests', label: 'nav:sales-requests',
icon: ClipboardList, icon: ClipboardList,
path: '/app/sales/requests/index', path: '/app/sales/requests/index',
moduleKey: 'SALES.REQUEST', moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
}, },
{ {
key: 'sales-orders', key: 'sales-orders',
label: 'nav:sales-orders', label: 'nav:sales-orders',
icon: Box, icon: Box,
path: '/app/sales/orders/index', path: '/app/sales/orders/index',
moduleKey: 'SALES.ORDER', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
}, },
{ {
key: 'sales-invoices', key: 'sales-invoices',
label: 'nav:sales-invoices', label: 'nav:sales-invoices',
icon: Receipt, icon: Receipt,
path: '/app/sales/invoices/index', path: '/app/sales/invoices/index',
moduleKey: 'SALES.INVOICE', moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
}, },
{ {
key: 'sales-payments', key: 'sales-payments',
label: 'nav:sales-payments', label: 'nav:sales-payments',
icon: CreditCard, icon: CreditCard,
path: '/app/sales/payments/index', path: '/app/sales/payments/index',
moduleKey: 'SALES.PAYMENT', moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
}, },
{ {
key: 'sales-plans', key: 'sales-plans',
label: 'nav:sales-plans', label: 'nav:sales-plans',
icon: Calendar, icon: Calendar,
path: '/app/sales/plans/index', path: '/app/sales/plans/index',
moduleKey: 'SALES.PLAN', moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
}, },
], ],
}, },
@@ -109,7 +109,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:sales-reports', label: 'nav:sales-reports',
icon: FileText, icon: FileText,
path: '/app/sales/reports/index', path: '/app/sales/reports/index',
moduleKey: 'SALES.REPORT', moduleKey: 'ADMIN.SALES.REPORT',
}, },
], ],
}, },
@@ -130,14 +130,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-employees', label: 'nav:configuration-employees',
icon: Users, icon: Users,
path: '/app/logistics/employees/index', path: '/app/logistics/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE', moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
}, },
{ {
key: 'logistics-cycles', key: 'logistics-cycles',
label: 'nav:logistics-cycles', label: 'nav:logistics-cycles',
icon: Repeat, icon: Repeat,
path: '/app/logistics/cycles/index', path: '/app/logistics/cycles/index',
moduleKey: 'LOGISTICS.CYCLE', moduleKey: 'ADMIN.LOGISTICS.DATA.CYCLE',
}, },
], ],
}, },
@@ -152,14 +152,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:logistics-packing-slips', label: 'nav:logistics-packing-slips',
icon: Package, icon: Package,
path: '/app/logistics/packing-slips/index', path: '/app/logistics/packing-slips/index',
moduleKey: 'SALES.PACKING_SLIP', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
}, },
{ {
key: 'logistics-plans', key: 'logistics-plans',
label: 'nav:logistics-plans', label: 'nav:logistics-plans',
icon: Calendar, icon: Calendar,
path: '/app/logistics/plans/index', path: '/app/logistics/plans/index',
moduleKey: 'LOGISTICS.PLAN', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
}, },
], ],
}, },
@@ -168,7 +168,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:logistics-reports', label: 'nav:logistics-reports',
icon: FileText, icon: FileText,
path: '/app/logistics/reports/index', path: '/app/logistics/reports/index',
moduleKey: 'LOGISTICS.REPORT', moduleKey: 'ADMIN.LOGISTICS.REPORT',
}, },
], ],
}, },
@@ -189,28 +189,28 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-branches', label: 'nav:configuration-branches',
icon: MapPin, icon: MapPin,
path: '/app/configuration/branches/index', path: '/app/configuration/branches/index',
moduleKey: 'CONFIGURATION.BRANCH', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
}, },
{ {
key: 'configuration-divisions', key: 'configuration-divisions',
label: 'nav:configuration-divisions', label: 'nav:configuration-divisions',
icon: Layers, icon: Layers,
path: '/app/configuration/divisions/index', path: '/app/configuration/divisions/index',
moduleKey: 'CONFIGURATION.DIVISION', moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
}, },
{ {
key: 'configuration-customers', key: 'configuration-customers',
label: 'nav:configuration-customers', label: 'nav:configuration-customers',
icon: Users, icon: Users,
path: '/app/configuration/customers/index', path: '/app/configuration/customers/index',
moduleKey: 'CONFIGURATION.CUSTOMER', moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
}, },
{ {
key: 'configuration-products', key: 'configuration-products',
label: 'nav:configuration-products', label: 'nav:configuration-products',
icon: Package, icon: Package,
path: '/app/configuration/products/index', path: '/app/configuration/products/index',
moduleKey: 'CONFIGURATION.PRODUCT', moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
}, },
], ],
}, },
@@ -225,14 +225,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:system-users', label: 'nav:system-users',
icon: Users, icon: Users,
path: '/app/system/users/index', path: '/app/system/users/index',
moduleKey: 'USERS', moduleKey: 'ADMIN.SETTINGS.USER.USERS',
}, },
{ {
key: 'system-privileges', key: 'system-privileges',
label: 'nav:system-privileges', label: 'nav:system-privileges',
icon: Shield, icon: Shield,
path: '/app/system/privileges/index', path: '/app/system/privileges/index',
moduleKey: 'PRIVILEGES', moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
}, },
], ],
}, },
@@ -33,7 +33,7 @@ describe('BranchesRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new BranchesRemoteDataServices(httpClient, { service = new BranchesRemoteDataServices(httpClient, {
apiUrl: '/branches', apiUrl: '/branches',
moduleKey: 'CONFIGURATION.BRANCH', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
transformer: new BranchesRemoteDataTransformer(), transformer: new BranchesRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { BranchEntity } from '../entities'; import type { BranchEntity } from '../entities';
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = { export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
moduleKey: 'CONFIGURATION.BRANCH', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
translationNamespace: 'BRANCHES', translationNamespace: 'BRANCHES',
apiUrl: '/branches', apiUrl: '/branches',
webUrl: '/app/configuration/branches', webUrl: '/app/configuration/branches',
@@ -42,7 +42,7 @@ describe('CustomersRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new CustomersRemoteDataServices(httpClient, { service = new CustomersRemoteDataServices(httpClient, {
apiUrl: '/customers', apiUrl: '/customers',
moduleKey: 'CONFIGURATION.CUSTOMER', moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
transformer: new CustomersRemoteDataTransformer(), transformer: new CustomersRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { CustomerEntity } from '../entities'; import type { CustomerEntity } from '../entities';
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = { export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
moduleKey: 'CONFIGURATION.CUSTOMER', moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
translationNamespace: 'CUSTOMERS', translationNamespace: 'CUSTOMERS',
apiUrl: '/customers', apiUrl: '/customers',
webUrl: '/app/configuration/customers', webUrl: '/app/configuration/customers',
@@ -44,7 +44,7 @@ describe('DivisionsRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new DivisionsRemoteDataServices(httpClient, { service = new DivisionsRemoteDataServices(httpClient, {
apiUrl: '/divisions', apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION', moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
transformer: new DivisionsRemoteDataTransformer(), transformer: new DivisionsRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { DivisionEntity } from '../entities'; import type { DivisionEntity } from '../entities';
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = { export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
moduleKey: 'CONFIGURATION.DIVISION', moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
translationNamespace: 'DIVISIONS', translationNamespace: 'DIVISIONS',
apiUrl: '/divisions', apiUrl: '/divisions',
webUrl: '/app/configuration/divisions', webUrl: '/app/configuration/divisions',
@@ -33,7 +33,7 @@ describe('EmployeesRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new EmployeesRemoteDataServices(httpClient, { service = new EmployeesRemoteDataServices(httpClient, {
apiUrl: '/employees', apiUrl: '/employees',
moduleKey: 'CONFIGURATION.EMPLOYEE', moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
transformer: new EmployeesRemoteDataTransformer(), transformer: new EmployeesRemoteDataTransformer(),
}); });
}); });
@@ -14,7 +14,7 @@ describe('employee purpose helpers', () => {
}); });
it('keeps a shared employee privilege key', () => { it('keeps a shared employee privilege key', () => {
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE'); expect(createEmployeeModuleConfig('sales').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE'); expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
}); });
}); });
@@ -4,7 +4,7 @@ import type { EmployeeEntity } from '../entities';
export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> { export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
return { return {
moduleKey: 'CONFIGURATION.EMPLOYEE', moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
translationNamespace: 'EMPLOYEES', translationNamespace: 'EMPLOYEES',
apiUrl: '/employees', apiUrl: '/employees',
webUrl: `/app/${purpose}/employees`, webUrl: `/app/${purpose}/employees`,
@@ -33,7 +33,7 @@ describe('ProductsRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new ProductsRemoteDataServices(httpClient, { service = new ProductsRemoteDataServices(httpClient, {
apiUrl: '/products', apiUrl: '/products',
moduleKey: 'CONFIGURATION.PRODUCT', moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
transformer: new ProductsRemoteDataTransformer(), transformer: new ProductsRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { ProductEntity } from '../entities'; import type { ProductEntity } from '../entities';
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = { export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
moduleKey: 'CONFIGURATION.PRODUCT', moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
translationNamespace: 'PRODUCTS', translationNamespace: 'PRODUCTS',
apiUrl: '/products', apiUrl: '/products',
webUrl: '/app/configuration/products', webUrl: '/app/configuration/products',
@@ -33,7 +33,7 @@ describe('CyclesRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new CyclesRemoteDataServices(httpClient, { service = new CyclesRemoteDataServices(httpClient, {
apiUrl: '/cycles', apiUrl: '/cycles',
moduleKey: 'SALES.CYCLE', moduleKey: 'ADMIN.SALES.DATA.CYCLE',
transformer: new CyclesRemoteDataTransformer('sales'), transformer: new CyclesRemoteDataTransformer('sales'),
}); });
}); });
@@ -5,7 +5,7 @@ import type { CycleEntity, CycleWeekdayRow } from '../entities';
export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> { export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> {
return { return {
moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE', moduleKey: purpose === 'sales' ? 'ADMIN.SALES.DATA.CYCLE' : 'ADMIN.LOGISTICS.DATA.CYCLE',
translationNamespace: 'CYCLES', translationNamespace: 'CYCLES',
apiUrl: '/cycles', apiUrl: '/cycles',
webUrl: `/app/${purpose}/cycles`, webUrl: `/app/${purpose}/cycles`,
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string }; export type ReportShellEntity = BaseEntity & { id: string };
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = { export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'LOGISTICS.REPORT', moduleKey: 'ADMIN.LOGISTICS.REPORT',
translationNamespace: 'LOGISTICS_REPORTS', translationNamespace: 'LOGISTICS_REPORTS',
apiUrl: '/reports', apiUrl: '/reports',
webUrl: '/app/logistics/reports', webUrl: '/app/logistics/reports',
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PackingSlipEntity } from '../entities'; import type { PackingSlipEntity } from '../entities';
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = { export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
moduleKey: 'SALES.PACKING_SLIP', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
translationNamespace: 'PACKING_SLIPS', translationNamespace: 'PACKING_SLIPS',
apiUrl: '/packing-slips', apiUrl: '/packing-slips',
webUrl: '/app/logistics/packing-slips', webUrl: '/app/logistics/packing-slips',
@@ -35,7 +35,7 @@ describe('PlansRemoteDataServices', () => {
httpClient, httpClient,
{ {
apiUrl: '/plans', apiUrl: '/plans',
moduleKey: 'SALES.PLAN', moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
transformer: new PlansRemoteDataTransformer('sales'), transformer: new PlansRemoteDataTransformer('sales'),
}, },
'sales', 'sales',
@@ -4,7 +4,7 @@ import type { PlanEntity } from '../entities';
export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<PlanEntity> { export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<PlanEntity> {
return { return {
moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN', moduleKey: purpose === 'sales' ? 'ADMIN.SALES.ACTIVITIES.PLAN' : 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
translationNamespace: 'PLANS', translationNamespace: 'PLANS',
apiUrl: '/plans', apiUrl: '/plans',
webUrl: `/app/${purpose}/plans`, webUrl: `/app/${purpose}/plans`,
@@ -5,12 +5,12 @@ import type { LookupEntity } from './lookup.entity';
export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, { export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, {
apiUrl: '/sales-invoices', apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE', moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
}); });
export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, { export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, {
apiUrl: '/packing-slips', apiUrl: '/packing-slips',
moduleKey: 'SALES.PACKING_SLIP', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
}); });
export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) => export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) =>
@@ -33,7 +33,7 @@ describe('SalesInvoicesRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new SalesInvoicesRemoteDataServices(httpClient, { service = new SalesInvoicesRemoteDataServices(httpClient, {
apiUrl: '/sales-invoices', apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE', moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
transformer: new SalesInvoicesRemoteDataTransformer(), transformer: new SalesInvoicesRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesInvoiceEntity } from '../entities'; import type { SalesInvoiceEntity } from '../entities';
export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = { export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = {
moduleKey: 'SALES.INVOICE', moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
translationNamespace: 'SALES_INVOICES', translationNamespace: 'SALES_INVOICES',
apiUrl: '/sales-invoices', apiUrl: '/sales-invoices',
webUrl: '/app/sales/invoices', webUrl: '/app/sales/invoices',
@@ -33,7 +33,7 @@ describe('SalesOrdersRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new SalesOrdersRemoteDataServices(httpClient, { service = new SalesOrdersRemoteDataServices(httpClient, {
apiUrl: '/sales-orders', apiUrl: '/sales-orders',
moduleKey: 'SALES.ORDER', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
transformer: new SalesOrdersRemoteDataTransformer(), transformer: new SalesOrdersRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesOrderEntity } from '../entities'; import type { SalesOrderEntity } from '../entities';
export const salesOrdersModuleConfig: ModuleConfigEntity<SalesOrderEntity> = { export const salesOrdersModuleConfig: ModuleConfigEntity<SalesOrderEntity> = {
moduleKey: 'SALES.ORDER', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
translationNamespace: 'SALES_ORDERS', translationNamespace: 'SALES_ORDERS',
apiUrl: '/sales-orders', apiUrl: '/sales-orders',
webUrl: '/app/sales/orders', webUrl: '/app/sales/orders',
@@ -33,7 +33,7 @@ describe('SalesPaymentsRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new SalesPaymentsRemoteDataServices(httpClient, { service = new SalesPaymentsRemoteDataServices(httpClient, {
apiUrl: '/sales-payments', apiUrl: '/sales-payments',
moduleKey: 'SALES.PAYMENT', moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
transformer: new SalesPaymentsRemoteDataTransformer(), transformer: new SalesPaymentsRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesPaymentEntity } from '../entities'; import type { SalesPaymentEntity } from '../entities';
export const salesPaymentsModuleConfig: ModuleConfigEntity<SalesPaymentEntity> = { export const salesPaymentsModuleConfig: ModuleConfigEntity<SalesPaymentEntity> = {
moduleKey: 'SALES.PAYMENT', moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
translationNamespace: 'SALES_PAYMENTS', translationNamespace: 'SALES_PAYMENTS',
apiUrl: '/sales-payments', apiUrl: '/sales-payments',
webUrl: '/app/sales/payments', webUrl: '/app/sales/payments',
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string }; export type ReportShellEntity = BaseEntity & { id: string };
export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = { export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'SALES.REPORT', moduleKey: 'ADMIN.SALES.REPORT',
translationNamespace: 'SALES_REPORTS', translationNamespace: 'SALES_REPORTS',
apiUrl: '/reports', apiUrl: '/reports',
webUrl: '/app/sales/reports', webUrl: '/app/sales/reports',
@@ -33,7 +33,7 @@ describe('SalesRequestsRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new SalesRequestsRemoteDataServices(httpClient, { service = new SalesRequestsRemoteDataServices(httpClient, {
apiUrl: '/sales-requests', apiUrl: '/sales-requests',
moduleKey: 'SALES.REQUEST', moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
transformer: new SalesRequestsRemoteDataTransformer(), transformer: new SalesRequestsRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesRequestEntity } from '../entities'; import type { SalesRequestEntity } from '../entities';
export const salesRequestsModuleConfig: ModuleConfigEntity<SalesRequestEntity> = { export const salesRequestsModuleConfig: ModuleConfigEntity<SalesRequestEntity> = {
moduleKey: 'SALES.REQUEST', moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
translationNamespace: 'SALES_REQUESTS', translationNamespace: 'SALES_REQUESTS',
apiUrl: '/sales-requests', apiUrl: '/sales-requests',
webUrl: '/app/sales/requests', webUrl: '/app/sales/requests',
@@ -32,7 +32,7 @@ describe('SalesDocumentRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new SalesDocumentRemoteDataServices(httpClient, { service = new SalesDocumentRemoteDataServices(httpClient, {
apiUrl: '/sales-requests', apiUrl: '/sales-requests',
moduleKey: 'SALES.REQUEST', moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
}); });
}); });
@@ -45,7 +45,7 @@ describe('PrivilegesRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new PrivilegesRemoteDataServices(httpClient, { service = new PrivilegesRemoteDataServices(httpClient, {
apiUrl: '/privileges', apiUrl: '/privileges',
moduleKey: 'PRIVILEGES', moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
transformer: new PrivilegesRemoteDataTransformer(), transformer: new PrivilegesRemoteDataTransformer(),
}); });
}); });
@@ -125,7 +125,7 @@ describe('PrivilegesRemoteDataServices', () => {
it('lists privilege keys from /privilege-keys', async () => { it('lists privilege keys from /privilege-keys', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({ vi.mocked(httpClient.request).mockResolvedValueOnce({
data: { data: {
data: [{ id: 'key-1', code: 'SALES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }], data: [{ id: 'key-1', code: 'ADMIN.SALES.ACTIVITIES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 }, meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 },
}, },
status: 200, status: 200,
@@ -142,7 +142,7 @@ describe('PrivilegesRemoteDataServices', () => {
); );
expect(result.data[0]).toEqual({ expect(result.data[0]).toEqual({
id: 'key-1', id: 'key-1',
code: 'SALES.INVOICE', code: 'ADMIN.SALES.ACTIVITIES.INVOICE',
label: 'Sales Invoice', label: 'Sales Invoice',
sortOrder: 1, sortOrder: 1,
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PrivilegeEntity } from '../entities'; import type { PrivilegeEntity } from '../entities';
export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = { export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = {
moduleKey: 'PRIVILEGES', moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
translationNamespace: 'PRIVILEGES', translationNamespace: 'PRIVILEGES',
apiUrl: '/privileges', apiUrl: '/privileges',
webUrl: '/app/system/privileges', webUrl: '/app/system/privileges',
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { EMPTY_MATRIX_CELL } from './entities';
import type { PrivilegeKeyEntity } from './entities';
import { groupPrivilegeKeys, flattenParentSectionRows, parsePrivilegeKey } from './privilege-key-hierarchy';
const keys: PrivilegeKeyEntity[] = [
{ id: 'k1', code: 'ADMIN.SALES.DATA.CYCLE', label: 'Sales cycles', sortOrder: 1 },
{ id: 'k2', code: 'ADMIN.SALES.ACTIVITIES.PLAN', label: 'Sales plans', sortOrder: 2 },
{ id: 'k3', code: 'MOBILE.SALES.PLAN', label: 'Sales plans (mobile)', sortOrder: 3 },
{ id: 'k4', code: 'MOBILE.SALES.PLAN.ATTENDANCE', label: 'Branch attendance', sortOrder: 4 },
];
function row(key: PrivilegeKeyEntity, view = false) {
return {
keyId: key.id,
code: key.code,
label: key.label,
sortOrder: key.sortOrder,
cell: { ...EMPTY_MATRIX_CELL, view },
};
}
describe('parsePrivilegeKey', () => {
it('parses 3-part keys', () => {
expect(parsePrivilegeKey('MOBILE.SALES.PLAN')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: null,
});
});
it('parses 4-part keys', () => {
expect(parsePrivilegeKey('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: 'ATTENDANCE',
});
});
it('returns null for legacy keys', () => {
expect(parsePrivilegeKey('PRIVILEGES')).toBeNull();
});
});
describe('groupPrivilegeKeys', () => {
it('groups keys into tabs, parents, modules, and submodule rows', () => {
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
expect(grouped.map((tab) => tab.group)).toEqual(['ADMIN', 'MOBILE']);
expect(grouped[0]?.parents[0]?.parent).toBe('SALES');
expect(grouped[0]?.parents[0]?.modules.map((m) => m.module)).toEqual([
'ACTIVITIES',
'DATA',
]);
expect(grouped[1]?.parents[0]?.modules[0]?.directRow?.code).toBe('MOBILE.SALES.PLAN');
expect(grouped[1]?.parents[0]?.modules[0]?.submodules[0]?.code).toBe(
'MOBILE.SALES.PLAN.ATTENDANCE',
);
});
});
describe('flattenParentSectionRows', () => {
it('flattens modules and submodules into sortable table rows', () => {
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
const salesParent = grouped[0]?.parents[0];
expect(salesParent).toBeDefined();
const tableRows = flattenParentSectionRows(salesParent!);
expect(tableRows.map((r) => r.code)).toEqual([
'ADMIN.SALES.DATA.CYCLE',
'ADMIN.SALES.ACTIVITIES.PLAN',
]);
expect(tableRows[0]).toMatchObject({ module: 'DATA', submodule: 'CYCLE' });
expect(tableRows[1]).toMatchObject({ module: 'ACTIVITIES', submodule: 'PLAN' });
});
});
@@ -0,0 +1,140 @@
import type { PrivilegeKeyEntity, PrivilegeMatrixCell } from './entities';
export type ParsedPrivilegeKey = {
group: string;
parent: string;
module: string;
submodule: string | null;
};
export type PrivilegeMatrixRow = {
keyId: string;
code: string;
label: string;
sortOrder: number;
cell: PrivilegeMatrixCell;
};
export type PrivilegeModuleGroup = {
module: string;
directRow?: PrivilegeMatrixRow;
submodules: PrivilegeMatrixRow[];
};
export type PrivilegeParentSection = {
parent: string;
modules: PrivilegeModuleGroup[];
};
export type PrivilegeGroupTab = {
group: string;
parents: PrivilegeParentSection[];
};
export function parsePrivilegeKey(code: string): ParsedPrivilegeKey | null {
const parts = code.split('.');
if (parts.length === 3) {
const [group, parent, module] = parts;
return { group, parent, module, submodule: null };
}
if (parts.length === 4) {
const [group, parent, module, submodule] = parts;
return { group, parent, module, submodule };
}
return null;
}
export function groupPrivilegeKeys(
keys: PrivilegeKeyEntity[],
rowsByKeyId: Record<string, PrivilegeMatrixRow>,
): PrivilegeGroupTab[] {
const groupMap = new Map<string, Map<string, Map<string, PrivilegeModuleGroup>>>();
const sortedKeys = [...keys].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
for (const key of sortedKeys) {
const parsed = parsePrivilegeKey(key.code);
if (!parsed) {
continue;
}
const row = rowsByKeyId[key.id];
if (!row) {
continue;
}
if (!groupMap.has(parsed.group)) {
groupMap.set(parsed.group, new Map());
}
const parentMap = groupMap.get(parsed.group)!;
if (!parentMap.has(parsed.parent)) {
parentMap.set(parsed.parent, new Map());
}
const moduleMap = parentMap.get(parsed.parent)!;
if (!moduleMap.has(parsed.module)) {
moduleMap.set(parsed.module, { module: parsed.module, submodules: [] });
}
const moduleGroup = moduleMap.get(parsed.module)!;
if (parsed.submodule === null) {
moduleGroup.directRow = row;
} else {
moduleGroup.submodules.push(row);
}
}
return [...groupMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, parentMap]) => ({
group,
parents: [...parentMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([parent, moduleMap]) => ({
parent,
modules: [...moduleMap.values()]
.map((moduleGroup) => ({
...moduleGroup,
submodules: [...moduleGroup.submodules].sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
),
}))
.sort((a, b) => a.module.localeCompare(b.module)),
})),
}));
}
export type PrivilegeTableRow = PrivilegeMatrixRow & {
module: string;
submodule: string | null;
};
export function flattenParentSectionRows(parent: PrivilegeParentSection): PrivilegeTableRow[] {
const rows: PrivilegeTableRow[] = [];
for (const moduleGroup of parent.modules) {
if (moduleGroup.directRow) {
rows.push({
...moduleGroup.directRow,
module: moduleGroup.module,
submodule: null,
});
}
for (const subRow of moduleGroup.submodules) {
const parsed = parsePrivilegeKey(subRow.code);
rows.push({
...subRow,
module: moduleGroup.module,
submodule: parsed?.submodule ?? null,
});
}
}
return rows.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
export function formatPrivilegeSegment(segment: string): string {
return segment
.split('_')
.map((part) => part.charAt(0) + part.slice(1).toLowerCase())
.join(' ');
}
@@ -17,7 +17,7 @@ describe('buildPrivilegeMatrixRows', () => {
details: [ details: [
{ {
privilegeKeyId: 'key-2', privilegeKeyId: 'key-2',
keyCode: 'USERS', keyCode: 'ADMIN.SETTINGS.USER.USERS',
keyLabel: 'Users', keyLabel: 'Users',
sortOrder: 2, sortOrder: 2,
action: 'view', action: 'view',
@@ -17,7 +17,7 @@ const detailDto: PrivilegeDetailResponseDto = {
{ {
id: 'd1', id: 'd1',
privilegeKeyId: 'key-1', privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE', keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice', keyLabel: 'Sales Invoice',
sortOrder: 1, sortOrder: 1,
action: 'view', action: 'view',
@@ -26,7 +26,7 @@ const detailDto: PrivilegeDetailResponseDto = {
{ {
id: 'd2', id: 'd2',
privilegeKeyId: 'key-1', privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE', keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice', keyLabel: 'Sales Invoice',
sortOrder: 1, sortOrder: 1,
action: 'create', action: 'create',
@@ -48,7 +48,7 @@ describe('PrivilegesRemoteDataTransformer', () => {
}); });
expect(entity.details?.[0]).toMatchObject({ expect(entity.details?.[0]).toMatchObject({
privilegeKeyId: 'key-1', privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE', keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice', keyLabel: 'Sales Invoice',
sortOrder: 1, sortOrder: 1,
}); });
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Box, Paper, Table, Text } from '@repo/ui/components'; import { Paper, Text } from '@repo/ui/components';
import { FieldCheckbox } from '@repo/ui/form';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { privilegesDataService } from '../../../domain/factories'; import { privilegesDataService } from '../../../domain/factories';
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeKeyEntity } from '../../../domain/entities'; import { EMPTY_MATRIX_CELL, type PrivilegeKeyEntity } from '../../../domain/entities';
import { groupPrivilegeKeys } from '../../../domain/privilege-key-hierarchy';
import { PrivilegeMatrixLayout } from '../privilege-matrix-layout';
export function FormPermissions() { export function FormPermissions() {
const { formControl } = useFormPageContext(); const { formControl } = useFormPageContext();
@@ -28,42 +29,28 @@ export function FormPermissions() {
}; };
}, [formControl]); }, [formControl]);
const grouped = useMemo(() => {
const rowsByKeyId = Object.fromEntries(
keys.map((key) => [
key.id,
{
keyId: key.id,
code: key.code,
label: key.label,
sortOrder: key.sortOrder,
cell: formControl.getValues(`matrix.${key.id}`) ?? { ...EMPTY_MATRIX_CELL },
},
]),
);
return groupPrivilegeKeys(keys, rowsByKeyId);
}, [keys, formControl]);
return ( return (
<Paper withBorder shadow="sm" radius="md" p="xl"> <Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md"> <Text fw={600} mb="md">
{t('section_permissions')} {t('section_permissions')}
</Text> </Text>
<Box style={{ overflowX: 'auto' }}> <PrivilegeMatrixLayout groups={grouped} t={t} mode="edit" formControl={formControl} />
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center">
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{keys.map((key) => (
<Table.Tr key={key.id}>
<Table.Td>
<Text size="sm">{key.label || key.code}</Text>
<Text size="xs" c="dimmed">
{key.code}
</Text>
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
<FieldCheckbox control={formControl.control} name={`matrix.${key.id}.${action}`} label="" />
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Paper> </Paper>
); );
} }
@@ -0,0 +1,179 @@
import { Box, Stack, Table, Tabs, Text } from '@repo/ui/components';
import { FieldCheckbox } from '@repo/ui/form';
import type { Control } from 'react-hook-form';
import { Check, Minus } from 'lucide-react';
import { PRIVILEGE_ACTIONS } from '../../domain/entities';
import {
flattenParentSectionRows,
formatPrivilegeSegment,
type PrivilegeGroupTab,
type PrivilegeMatrixRow,
type PrivilegeTableRow,
} from '../../domain/privilege-key-hierarchy';
const MODULE_COL_WIDTH = '22%';
const SUBMODULE_COL_WIDTH = '30%';
const ACTION_COL_WIDTH = 88;
type PrivilegeMatrixLayoutProps = {
groups: PrivilegeGroupTab[];
t: (key: string) => string;
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
};
function segmentLabel(t: (key: string) => string, kind: string, value: string) {
const key = `hierarchy_${kind}_${value.toLowerCase()}`;
const translated = t(key);
return translated === key ? formatPrivilegeSegment(value) : translated;
}
function submoduleCellLabel(
t: (key: string) => string,
row: PrivilegeTableRow,
) {
if (row.submodule) {
return row.label || segmentLabel(t, 'submodule', row.submodule);
}
return row.label || segmentLabel(t, 'module', row.module);
}
function MatrixActionCells({
row,
mode,
formControl,
}: {
row: PrivilegeMatrixRow;
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
}) {
return (
<>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center" w={ACTION_COL_WIDTH} miw={ACTION_COL_WIDTH} maw={ACTION_COL_WIDTH}>
<Box display="flex" style={{ justifyContent: 'center', alignItems: 'center' }}>
{mode === 'edit' && formControl ? (
<FieldCheckbox
control={formControl.control}
name={`matrix.${row.keyId}.${action}`}
label=""
/>
) : row.cell[action] ? (
<Check size={16} />
) : (
<Minus size={16} />
)}
</Box>
</Table.Td>
))}
</>
);
}
function ParentMatrixTable({
rows,
mode,
formControl,
t,
}: {
rows: PrivilegeTableRow[];
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
t: (key: string) => string;
}) {
if (rows.length === 0) {
return null;
}
return (
<Box style={{ overflowX: 'auto' }}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
layout="fixed"
w="100%"
style={{ tableLayout: 'fixed' }}
>
<colgroup>
<col style={{ width: MODULE_COL_WIDTH }} />
<col style={{ width: SUBMODULE_COL_WIDTH }} />
{PRIVILEGE_ACTIONS.map((action) => (
<col key={action} style={{ width: ACTION_COL_WIDTH }} />
))}
</colgroup>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
<Table.Th>{t('matrix_submodule')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center" w={ACTION_COL_WIDTH}>
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.keyId}>
<Table.Td valign="middle">
<Text size="sm" fw={500}>
{segmentLabel(t, 'module', row.module)}
</Text>
</Table.Td>
<Table.Td valign="middle">
<Text size="sm">{submoduleCellLabel(t, row)}</Text>
<Text size="xs" c="dimmed">
{row.code}
</Text>
</Table.Td>
<MatrixActionCells row={row} mode={mode} formControl={formControl} />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
}
export function PrivilegeMatrixLayout({
groups,
t,
mode,
formControl,
}: PrivilegeMatrixLayoutProps) {
if (groups.length === 0) {
return null;
}
return (
<Tabs defaultValue={groups[0]?.group} variant="outline" radius="md">
<Tabs.List mb="md">
{groups.map((groupTab) => (
<Tabs.Tab key={groupTab.group} value={groupTab.group}>
{segmentLabel(t, 'group', groupTab.group)}
</Tabs.Tab>
))}
</Tabs.List>
{groups.map((groupTab) => (
<Tabs.Panel key={groupTab.group} value={groupTab.group} pt="md">
<Stack gap="lg">
{groupTab.parents.map((parentSection) => (
<Stack key={parentSection.parent} gap="sm">
<Text fw={600}>{segmentLabel(t, 'parent', parentSection.parent)}</Text>
<ParentMatrixTable
rows={flattenParentSectionRows(parentSection)}
mode={mode}
formControl={formControl}
t={t}
/>
</Stack>
))}
</Stack>
</Tabs.Panel>
))}
</Tabs>
);
}
@@ -1,7 +1,9 @@
import { Box, Paper, Table, Text } from '@repo/ui/components'; import { useMemo } from 'react';
import { Check, Minus } from 'lucide-react'; import { Paper, Text } from '@repo/ui/components';
import { PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../domain/entities'; import { PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../domain/entities';
import { buildPrivilegeMatrixRows } from '../../domain/privilege.matrix-rows'; import { buildPrivilegeMatrixRows } from '../../domain/privilege.matrix-rows';
import { groupPrivilegeKeys } from '../../domain/privilege-key-hierarchy';
import { PrivilegeMatrixLayout } from './privilege-matrix-layout';
export function PrivilegePermissionsTable({ export function PrivilegePermissionsTable({
privilege, privilege,
@@ -10,44 +12,26 @@ export function PrivilegePermissionsTable({
privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined; privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined;
t: (key: string) => string; t: (key: string) => string;
}) { }) {
const grouped = useMemo(() => {
const rows = buildPrivilegeMatrixRows(privilege); const rows = buildPrivilegeMatrixRows(privilege);
const keys = rows.map((row) => ({
id: row.keyId,
code: row.code,
label: row.label,
sortOrder: row.sortOrder,
}));
const rowsByKeyId = Object.fromEntries(rows.map((row) => [row.keyId, row]));
return groupPrivilegeKeys(keys, rowsByKeyId);
}, [privilege]);
void PRIVILEGE_ACTIONS;
return ( return (
<Paper withBorder shadow="sm" radius="md" p="xl"> <Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md"> <Text fw={600} mb="md">
{t('section_permissions')} {t('section_permissions')}
</Text> </Text>
<Box style={{ overflowX: 'auto' }}> <PrivilegeMatrixLayout groups={grouped} t={t} mode="read" />
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center">
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.keyId}>
<Table.Td>
<Text size="sm">{row.label}</Text>
<Text size="xs" c="dimmed">
{row.code}
</Text>
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Paper> </Paper>
); );
} }
@@ -12,11 +12,22 @@
"section_general": "General", "section_general": "General",
"section_permissions": "Permissions", "section_permissions": "Permissions",
"matrix_module": "Module", "matrix_module": "Module",
"matrix_submodule": "Submodule",
"matrix_view": "View", "matrix_view": "View",
"matrix_create": "Create", "matrix_create": "Create",
"matrix_update": "Update", "matrix_update": "Update",
"matrix_delete": "Delete", "matrix_delete": "Delete",
"matrix_import": "Import", "matrix_import": "Import",
"hierarchy_group_admin": "Admin",
"hierarchy_group_mobile": "Mobile",
"hierarchy_parent_sales": "Sales",
"hierarchy_parent_logistics": "Logistics",
"hierarchy_parent_settings": "Settings",
"hierarchy_module_data": "Data",
"hierarchy_module_activities": "Activities",
"hierarchy_module_report": "Report",
"hierarchy_module_user": "User",
"hierarchy_module_plan": "Plan",
"status_draft": "Draft", "status_draft": "Draft",
"status_active": "Active", "status_active": "Active",
"status_archived": "Archived" "status_archived": "Archived"
@@ -12,11 +12,22 @@
"section_general": "Umum", "section_general": "Umum",
"section_permissions": "Izin", "section_permissions": "Izin",
"matrix_module": "Modul", "matrix_module": "Modul",
"matrix_submodule": "Submodul",
"matrix_view": "Lihat", "matrix_view": "Lihat",
"matrix_create": "Buat", "matrix_create": "Buat",
"matrix_update": "Ubah", "matrix_update": "Ubah",
"matrix_delete": "Hapus", "matrix_delete": "Hapus",
"matrix_import": "Impor", "matrix_import": "Impor",
"hierarchy_group_admin": "Admin",
"hierarchy_group_mobile": "Mobile",
"hierarchy_parent_sales": "Penjualan",
"hierarchy_parent_logistics": "Logistik",
"hierarchy_parent_settings": "Pengaturan",
"hierarchy_module_data": "Data",
"hierarchy_module_activities": "Aktivitas",
"hierarchy_module_report": "Laporan",
"hierarchy_module_user": "Pengguna",
"hierarchy_module_plan": "Rencana",
"status_draft": "Draft", "status_draft": "Draft",
"status_active": "Aktif", "status_active": "Aktif",
"status_archived": "Diarsipkan" "status_archived": "Diarsipkan"
@@ -33,7 +33,7 @@ describe('UsersRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new UsersRemoteDataServices(httpClient, { service = new UsersRemoteDataServices(httpClient, {
apiUrl: '/users', apiUrl: '/users',
moduleKey: 'USERS', moduleKey: 'ADMIN.SETTINGS.USER.USERS',
transformer: new UsersRemoteDataTransformer(), transformer: new UsersRemoteDataTransformer(),
}); });
}); });
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { UserEntity } from '../entities'; import type { UserEntity } from '../entities';
export const usersModuleConfig: ModuleConfigEntity<UserEntity> = { export const usersModuleConfig: ModuleConfigEntity<UserEntity> = {
moduleKey: 'USERS', moduleKey: 'ADMIN.SETTINGS.USER.USERS',
translationNamespace: 'USERS', translationNamespace: 'USERS',
apiUrl: '/users', apiUrl: '/users',
webUrl: '/app/system/users', webUrl: '/app/system/users',
+18 -10
View File
@@ -1,12 +1,20 @@
export const MODULE_KEY = { export const MODULE_KEY = {
PRIVILEGES: 'PRIVILEGES', PRIVILEGES: 'ADMIN.SETTINGS.USER.PRIVILEGES',
USERS: 'USERS', USERS: 'ADMIN.SETTINGS.USER.USERS',
CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION', CONFIGURATION_DIVISION: 'ADMIN.SETTINGS.DATA.DIVISION',
CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH', CONFIGURATION_BRANCH: 'ADMIN.SETTINGS.DATA.BRANCH',
CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER', CONFIGURATION_CUSTOMER: 'ADMIN.SETTINGS.DATA.CUSTOMER',
CONFIGURATION_EMPLOYEE: 'CONFIGURATION.EMPLOYEE', CONFIGURATION_PRODUCT: 'ADMIN.SETTINGS.DATA.PRODUCT',
SALES_CYCLE: 'SALES.CYCLE', CONFIGURATION_EMPLOYEE: 'ADMIN.SALES.DATA.EMPLOYEE',
SALES_PLAN: 'SALES.PLAN', SALES_CYCLE: 'ADMIN.SALES.DATA.CYCLE',
LOGISTICS_CYCLE: 'LOGISTICS.CYCLE', SALES_REQUEST: 'ADMIN.SALES.ACTIVITIES.REQUEST',
LOGISTICS_PLAN: 'LOGISTICS.PLAN', SALES_ORDER: 'ADMIN.SALES.ACTIVITIES.ORDER',
SALES_INVOICE: 'ADMIN.SALES.ACTIVITIES.INVOICE',
SALES_PAYMENT: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
SALES_PLAN: 'ADMIN.SALES.ACTIVITIES.PLAN',
SALES_REPORT: 'ADMIN.SALES.REPORT',
PACKING_SLIP: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
LOGISTICS_CYCLE: 'ADMIN.LOGISTICS.DATA.CYCLE',
LOGISTICS_PLAN: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
LOGISTICS_REPORT: 'ADMIN.LOGISTICS.REPORT',
} as const; } as const;
+2 -2
View File
@@ -38,7 +38,7 @@ const me = {
isSuperadmin: false, isSuperadmin: false,
privilege: { id: 'priv-1', name: 'Admin', code: 'ADMIN' }, privilege: { id: 'priv-1', name: 'Admin', code: 'ADMIN' },
permissions: { permissions: {
PRIVILEGES: { view: true, create: false, update: false, delete: false }, 'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: false, update: false, delete: false },
}, },
}; };
@@ -83,7 +83,7 @@ describe('auth.helper', () => {
expect(appDatabase.setItem).toHaveBeenCalledWith( expect(appDatabase.setItem).toHaveBeenCalledWith(
'user_privilege', 'user_privilege',
expect.objectContaining({ expect.objectContaining({
PRIVILEGES: expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }), 'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
}), }),
); );
expect(replace).toHaveBeenCalledWith('/app'); expect(replace).toHaveBeenCalledWith('/app');
@@ -27,7 +27,7 @@ describe('enterpriseStorageAdapter', () => {
return null; return null;
}); });
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual(defaultPrivileges); await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual(defaultPrivileges);
await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges); await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges);
}); });
@@ -35,12 +35,12 @@ describe('enterpriseStorageAdapter', () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => { vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: false }; if (key === 'user_profile') return { isSuperadmin: false };
if (key === 'user_privilege') { if (key === 'user_privilege') {
return { PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: true } }; return { 'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges, ALLOW_VIEW: true } };
} }
return null; return null;
}); });
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual({ await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual({
...noPrivileges, ...noPrivileges,
ALLOW_VIEW: true, ALLOW_VIEW: true,
}); });
@@ -14,7 +14,7 @@ const items: TestMenuItem[] = [
{ {
key: 'system', key: 'system',
children: [ children: [
{ key: 'privileges', moduleKey: 'PRIVILEGES', path: '/app/system/privileges/index' }, { key: 'privileges', moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES', path: '/app/system/privileges/index' },
{ key: 'placeholder', path: '/app/system/other' }, { key: 'placeholder', path: '/app/system/other' },
], ],
}, },
@@ -34,7 +34,7 @@ describe('filterMenuByViewPrivilege', () => {
const filtered = filterMenuByViewPrivilege( const filtered = filterMenuByViewPrivilege(
items, items,
{ {
PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: false }, 'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges, ALLOW_VIEW: false },
EXAMPLE_FULL_PAGE: { ...noPrivileges, ALLOW_VIEW: true }, EXAMPLE_FULL_PAGE: { ...noPrivileges, ALLOW_VIEW: true },
}, },
false, false,
@@ -50,7 +50,7 @@ describe('filterMenuByViewPrivilege', () => {
const filtered = filterMenuByViewPrivilege( const filtered = filterMenuByViewPrivilege(
items, items,
{ {
PRIVILEGES: { ...noPrivileges }, 'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges },
EXAMPLE_FULL_PAGE: { ...noPrivileges }, EXAMPLE_FULL_PAGE: { ...noPrivileges },
}, },
false, false,
@@ -72,13 +72,13 @@ describe('filterMenuByViewPrivilege', () => {
{ {
key: 'sales', key: 'sales',
children: [ children: [
{ key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' }, { key: 'orders', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER', path: '/app/sales/orders/index' },
{ key: 'reports', path: '/app/sales/reports', isPlaceholder: true }, { key: 'reports', path: '/app/sales/reports', isPlaceholder: true },
], ],
}, },
]; ];
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges } }, false); const filtered = filterMenuByViewPrivilege(menu, { 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges } }, false);
expect(filtered.find((item) => item.key === 'sales')).toBeUndefined(); expect(filtered.find((item) => item.key === 'sales')).toBeUndefined();
}); });
@@ -88,13 +88,13 @@ describe('filterMenuByViewPrivilege', () => {
{ {
key: 'sales', key: 'sales',
children: [ children: [
{ key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' }, { key: 'orders', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER', path: '/app/sales/orders/index' },
{ key: 'reports', path: '/app/sales/reports', isPlaceholder: true }, { key: 'reports', path: '/app/sales/reports', isPlaceholder: true },
], ],
}, },
]; ];
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false); const filtered = filterMenuByViewPrivilege(menu, { 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false);
expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([ expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([
'orders', 'orders',
@@ -6,7 +6,7 @@ describe('mapUserPrivileges', () => {
it('maps view/create/update/delete/import flags', () => { it('maps view/create/update/delete/import flags', () => {
const result = mapUserPrivileges( const result = mapUserPrivileges(
{ {
PRIVILEGES: { 'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true, view: true,
create: true, create: true,
update: false, update: false,
@@ -17,7 +17,7 @@ describe('mapUserPrivileges', () => {
false, false,
); );
expect(result.PRIVILEGES).toEqual({ expect(result['ADMIN.SETTINGS.USER.PRIVILEGES']).toEqual({
...noPrivileges, ...noPrivileges,
ALLOW_VIEW: true, ALLOW_VIEW: true,
ALLOW_CREATE: true, ALLOW_CREATE: true,
@@ -28,9 +28,9 @@ describe('mapUserPrivileges', () => {
}); });
it('defaults missing ALLOW flags to false', () => { it('defaults missing ALLOW flags to false', () => {
const result = mapUserPrivileges({ CUSTOMERS: { view: true } }, false); const result = mapUserPrivileges({ 'ADMIN.SETTINGS.DATA.CUSTOMER': { view: true } }, false);
expect(result.CUSTOMERS).toEqual({ expect(result['ADMIN.SETTINGS.DATA.CUSTOMER']).toEqual({
...noPrivileges, ...noPrivileges,
ALLOW_VIEW: true, ALLOW_VIEW: true,
ALLOW_CREATE: false, ALLOW_CREATE: false,
@@ -42,7 +42,7 @@ describe('mapUserPrivileges', () => {
it('returns an empty map for superadmin (adapter grants full privileges)', () => { it('returns an empty map for superadmin (adapter grants full privileges)', () => {
const result = mapUserPrivileges( const result = mapUserPrivileges(
{ {
PRIVILEGES: { view: true, create: true, update: true, delete: true }, 'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: true, update: true, delete: true },
}, },
true, true,
); );
@@ -53,7 +53,7 @@ describe('TrackGoRemoteDataServices', () => {
httpClient = createMockHttpClient(); httpClient = createMockHttpClient();
service = new SampleRemoteDataServices(httpClient, { service = new SampleRemoteDataServices(httpClient, {
apiUrl: '/divisions', apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION', moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
transformer: new SampleTransformer(), transformer: new SampleTransformer(),
}); });
}); });