feat: add privileges management module with CRUD functionality
- Introduced a new Privileges module, including routes for creating, editing, and viewing privileges. - Implemented a detailed permissions matrix for managing actions (view, create, update, delete, import) associated with each privilege. - Added validation schema for privilege creation and editing. - Developed UI components for displaying and managing privilege details and permissions. - Integrated language support for English and Indonesian in the privileges module. - Created unit tests for the privileges remote data service and transformer to ensure functionality. This commit enhances the application by providing a comprehensive privileges management system, improving user role management and permissions handling.
This commit is contained in:
@@ -7,6 +7,7 @@ const ExampleModule = lazy(() => import('./modules/example'));
|
||||
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
||||
const SystemInformation = lazy(() => import('./modules/system/information'));
|
||||
const SystemNotification = lazy(() => import('./modules/system/notification'));
|
||||
const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory'));
|
||||
|
||||
export default function AppModule() {
|
||||
return (
|
||||
@@ -17,6 +18,7 @@ export default function AppModule() {
|
||||
<Route path="/system/setting" element={<SystemSetting />} />
|
||||
<Route path="/system/information" element={<SystemInformation />} />
|
||||
<Route path="/system/notifications" element={<SystemNotification />} />
|
||||
<Route path="/system/privileges/*" element={<PrivilegesModule />} />
|
||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
|
||||
@@ -235,6 +235,21 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-group',
|
||||
label: 'nav:system',
|
||||
icon: Shield,
|
||||
path: '/app/system',
|
||||
children: [
|
||||
{
|
||||
key: 'system-privileges',
|
||||
label: 'nav:system-privileges',
|
||||
icon: Shield,
|
||||
path: '/app/system/privileges/index',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'example-module',
|
||||
label: 'nav:example-module',
|
||||
@@ -246,6 +261,7 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:example-full-page',
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/example/full-page/index',
|
||||
moduleKey: 'EXAMPLE_FULL_PAGE',
|
||||
},
|
||||
// {
|
||||
// key: 'example-single-page',
|
||||
|
||||
@@ -33,5 +33,6 @@
|
||||
"example-module": "Example Module",
|
||||
"example-full-page": "Example Full Page",
|
||||
"example-single-page": "Example Single Page",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"system-privileges": "Privileges"
|
||||
}
|
||||
@@ -33,5 +33,6 @@
|
||||
"example-module": "Modul Contoh",
|
||||
"example-full-page": "Contoh Halaman Penuh",
|
||||
"example-single-page": "Contoh Halaman Tunggal",
|
||||
"system": "Sistem"
|
||||
"system": "Sistem",
|
||||
"system-privileges": "Hak Akses"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { HistoryDrawer } from './components/history';
|
||||
import { BookmarkDrawer } from './components/bookmark';
|
||||
import { useHistoryTracker } from './hooks/useHistoryTracker';
|
||||
import { MENU_ITEMS } from './data/menu.data';
|
||||
import { useFilteredMenuItems } from '../../../core/lib/use-filtered-menu-items';
|
||||
import { enterpriseStorageAdapter } from '../../../core/lib/enterprise-storage-adapter';
|
||||
|
||||
import navEn from './languages/en/nav.json';
|
||||
@@ -27,6 +28,7 @@ registerModuleNamespace('bookmark', { en: bookmarkEn, id: bookmarkId });
|
||||
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
// FIXME: To disable the history tracker, simply comment out or remove the following line:
|
||||
useHistoryTracker();
|
||||
const menuItems = useFilteredMenuItems(MENU_ITEMS);
|
||||
|
||||
const configAppShell: CoreAppShellConfig = {
|
||||
variant: 'header-first',
|
||||
@@ -50,7 +52,7 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
|
||||
header: <HeaderLayout />,
|
||||
sidebar: (
|
||||
<SidebarMenu
|
||||
items={MENU_ITEMS}
|
||||
items={menuItems}
|
||||
withToggle
|
||||
// FIXME: To hide the history and bookmark buttons from the sidebar, set these to false:
|
||||
// showHistory={false}
|
||||
@@ -59,7 +61,7 @@ export default function ModuleLayout({ children }: { children: React.ReactNode }
|
||||
),
|
||||
sidebarMobile: (
|
||||
<SidebarMenu
|
||||
items={MENU_ITEMS}
|
||||
items={menuItems}
|
||||
variantOverride="expanded"
|
||||
// FIXME: To hide the history and bookmark buttons from the sidebar, set these to false:
|
||||
// showHistory={false}
|
||||
|
||||
@@ -14,4 +14,6 @@ export interface MenuItemType {
|
||||
path: string;
|
||||
/** Optional nested child menu items */
|
||||
children?: MenuItemType[];
|
||||
/** Privilege catalog key used to hide the item when ALLOW_VIEW is false */
|
||||
moduleKey?: string;
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import { PrivilegesRemoteDataServices } from './privilege.remote.service';
|
||||
import { PrivilegesRemoteDataTransformer } from '../domain/transformers/privilege.remote.transformer';
|
||||
|
||||
function createMockHttpClient(): AxiosInstance {
|
||||
return {
|
||||
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
|
||||
defaults: {} as AxiosInstance['defaults'],
|
||||
interceptors: {
|
||||
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
|
||||
},
|
||||
getUri: vi.fn(),
|
||||
get: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
head: vi.fn(),
|
||||
options: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
postForm: vi.fn(),
|
||||
putForm: vi.fn(),
|
||||
patchForm: vi.fn(),
|
||||
} as unknown as AxiosInstance;
|
||||
}
|
||||
|
||||
const unwrappedDetail = {
|
||||
id: 'priv-1',
|
||||
name: 'Sales Staff',
|
||||
code: 'SALES_STAFF',
|
||||
status: 'active',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
createdBy: 'u1',
|
||||
updatedBy: 'u2',
|
||||
details: [],
|
||||
};
|
||||
|
||||
describe('PrivilegesRemoteDataServices', () => {
|
||||
let httpClient: AxiosInstance;
|
||||
let service: PrivilegesRemoteDataServices;
|
||||
|
||||
beforeEach(() => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new PrivilegesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/privileges',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
transformer: new PrivilegesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps an unwrapped getOne body as { data: entity }', async () => {
|
||||
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: unwrappedDetail, status: 200 });
|
||||
|
||||
const result = await service.getOne('priv-1');
|
||||
|
||||
expect(result.data).toEqual({
|
||||
data: expect.objectContaining({ id: 'priv-1', code: 'SALES_STAFF', matrix: {} }),
|
||||
});
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/privileges/priv-1', method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses PATCH when editing a privilege', async () => {
|
||||
await service.edit('priv-1', { name: 'Sales Staff', code: 'SALES_STAFF' });
|
||||
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/privileges/priv-1',
|
||||
method: 'PATCH',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('activates via PATCH /privileges/:id/status with status active', async () => {
|
||||
await service.activate('priv-1');
|
||||
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/privileges/priv-1/status',
|
||||
method: 'PATCH',
|
||||
data: { status: 'active' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivates via PATCH /privileges/:id/status with status archived', async () => {
|
||||
await service.deactivate('priv-1');
|
||||
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/privileges/priv-1/status',
|
||||
method: 'PATCH',
|
||||
data: { status: 'archived' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulk-deletes via POST /privileges/bulk-delete', async () => {
|
||||
await service.batchDelete(['priv-1', 'priv-2']);
|
||||
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/privileges/bulk-delete',
|
||||
method: 'POST',
|
||||
data: expect.objectContaining({ ids: ['priv-1', 'priv-2'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('bulk-activates via POST /privileges/bulk-status', async () => {
|
||||
await service.batchActivate(['priv-1']);
|
||||
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/privileges/bulk-status',
|
||||
method: 'POST',
|
||||
data: { ids: ['priv-1'], status: 'active' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists privilege keys from /privilege-keys', async () => {
|
||||
vi.mocked(httpClient.request).mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [{ id: 'key-1', code: 'SALES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
|
||||
meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 },
|
||||
},
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await service.listPrivilegeKeys();
|
||||
|
||||
expect(httpClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/privilege-keys',
|
||||
method: 'GET',
|
||||
params: { limit: 200 },
|
||||
}),
|
||||
);
|
||||
expect(result.data[0]).toEqual({
|
||||
id: 'key-1',
|
||||
code: 'SALES.INVOICE',
|
||||
label: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { AxiosInstance, AxiosRequestConfig } from '@repo/core-api/http-client';
|
||||
import { BaseRemoteDataServices, DESCRIPTORS, type DataServicesConfig } from '@repo/core-api/data-services';
|
||||
import type { ApiResponse } from '@repo/core-api/http-client';
|
||||
import { mapPrivilegeKey } from '../domain/transformers/privilege.remote.transformer';
|
||||
import type { PrivilegeDto, PrivilegeEntity, PrivilegeKeyDto, PrivilegeKeyEntity } from '../domain/entities';
|
||||
|
||||
function unwrapPrivilegeDetail(raw: unknown): PrivilegeDto {
|
||||
if (raw && typeof raw === 'object' && 'id' in raw && 'code' in raw && 'name' in raw) {
|
||||
return raw as PrivilegeDto;
|
||||
}
|
||||
if (raw && typeof raw === 'object' && 'data' in raw) {
|
||||
const nested = (raw as { data: unknown }).data;
|
||||
if (nested && typeof nested === 'object' && 'id' in nested) {
|
||||
return nested as PrivilegeDto;
|
||||
}
|
||||
}
|
||||
throw new Error('Unexpected privilege detail response');
|
||||
}
|
||||
|
||||
export class PrivilegesRemoteDataServices extends BaseRemoteDataServices<PrivilegeEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<PrivilegeEntity>) {
|
||||
const apiUrl = config.apiUrl ?? '/privileges';
|
||||
super(httpClient, {
|
||||
...config,
|
||||
urls: {
|
||||
batchDeleteUrl: `${apiUrl}/bulk-delete`,
|
||||
activateUrl: `${apiUrl}/:id/status`,
|
||||
deactivateUrl: `${apiUrl}/:id/status`,
|
||||
batchActivateUrl: `${apiUrl}/bulk-status`,
|
||||
batchDeactivateUrl: `${apiUrl}/bulk-status`,
|
||||
...config.urls,
|
||||
},
|
||||
methods: {
|
||||
editMethod: 'PATCH',
|
||||
batchDeleteMethod: 'POST',
|
||||
batchActivateMethod: 'POST',
|
||||
batchDeactivateMethod: 'POST',
|
||||
...config.methods,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getOne<T = { data: PrivilegeEntity }>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const result = await this.execute<PrivilegeDto | { data: PrivilegeDto }>(DESCRIPTORS.getOne, {
|
||||
variableURL: { id },
|
||||
config,
|
||||
});
|
||||
const dto = unwrapPrivilegeDetail(result.data);
|
||||
const entity = this.transformer
|
||||
? this.transformer.transformGetOneResponse
|
||||
? this.transformer.transformGetOneResponse(dto as unknown as PrivilegeEntity)
|
||||
: this.transformer.transformToEntity(dto as unknown as PrivilegeEntity)
|
||||
: (dto as unknown as PrivilegeEntity);
|
||||
return { ...result, data: { data: entity } as T };
|
||||
}
|
||||
|
||||
activate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||
return this.execute<void>(DESCRIPTORS.activate, {
|
||||
variableURL: { id },
|
||||
config: { ...config, data: { status: 'active' } },
|
||||
});
|
||||
}
|
||||
|
||||
deactivate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||
return this.execute<void>(DESCRIPTORS.deactivate, {
|
||||
variableURL: { id },
|
||||
config: { ...config, data: { status: 'archived' } },
|
||||
});
|
||||
}
|
||||
|
||||
batchActivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||
return this.execute<void>(DESCRIPTORS.batchActivate, {
|
||||
config: { ...config, data: { ids, status: 'active' } },
|
||||
});
|
||||
}
|
||||
|
||||
batchDeactivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
|
||||
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
|
||||
config: { ...config, data: { ids, status: 'archived' } },
|
||||
});
|
||||
}
|
||||
|
||||
async listPrivilegeKeys(): Promise<ApiResponse<PrivilegeKeyEntity[]>> {
|
||||
const result = await this.customRequest<{ data?: PrivilegeKeyDto[] } | PrivilegeKeyDto[]>({
|
||||
url: '/privilege-keys',
|
||||
method: 'GET',
|
||||
params: { limit: 200 },
|
||||
});
|
||||
const rows = Array.isArray(result.data) ? result.data : (result.data?.data ?? []);
|
||||
return { ...result, data: rows.map(mapPrivilegeKey) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './privilege.constants';
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { PrivilegeEntity } from '../entities';
|
||||
|
||||
export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = {
|
||||
moduleKey: 'PRIVILEGES',
|
||||
translationNamespace: 'PRIVILEGES',
|
||||
apiUrl: '/privileges',
|
||||
webUrl: '/app/system/privileges',
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './privilege.entity';
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
|
||||
export type PrivilegeStatus = 'draft' | 'active' | 'archived';
|
||||
export type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import';
|
||||
|
||||
export interface PrivilegeMatrixCell {
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
import: boolean;
|
||||
}
|
||||
|
||||
export interface PrivilegeDetailEntity {
|
||||
id?: string;
|
||||
privilegeKeyId: string;
|
||||
keyCode: string;
|
||||
keyLabel: string;
|
||||
sortOrder: number;
|
||||
action: PrivilegeAction;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface PrivilegeKeyEntity {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface PrivilegeEntity extends BaseEntity {
|
||||
name: string;
|
||||
code: string;
|
||||
status?: PrivilegeStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
details?: PrivilegeDetailEntity[];
|
||||
matrix?: Record<string, PrivilegeMatrixCell>;
|
||||
}
|
||||
|
||||
export interface PrivilegeDto {
|
||||
id?: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status?: PrivilegeStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
details?: PrivilegeDetailDto[];
|
||||
}
|
||||
|
||||
export interface PrivilegeDetailDto {
|
||||
id?: string;
|
||||
privilegeKeyId: string;
|
||||
keyCode?: string;
|
||||
keyLabel?: string;
|
||||
sortOrder?: number;
|
||||
action: PrivilegeAction;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface PrivilegeDetailInputDto {
|
||||
privilegeKeyId: string;
|
||||
action: PrivilegeAction;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface PrivilegeDetailResponseDto extends PrivilegeDto {
|
||||
details: PrivilegeDetailDto[];
|
||||
}
|
||||
|
||||
export interface PrivilegeKeyDto {
|
||||
id?: string;
|
||||
code?: string;
|
||||
keyCode?: string;
|
||||
label?: string;
|
||||
keyLabel?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export const PRIVILEGE_ACTIONS: PrivilegeAction[] = ['view', 'create', 'update', 'delete', 'import'];
|
||||
|
||||
export const EMPTY_MATRIX_CELL: PrivilegeMatrixCell = {
|
||||
view: false,
|
||||
create: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
import: false,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { PrivilegesRemoteDataServices } from '../../data/privilege.remote.service';
|
||||
import { privilegesModuleConfig } from '../constants/privilege.constants';
|
||||
import { PrivilegesRemoteDataTransformer } from '../transformers/privilege.remote.transformer';
|
||||
|
||||
export const privilegesDataTransformer = new PrivilegesRemoteDataTransformer();
|
||||
|
||||
export const privilegesDataService = new PrivilegesRemoteDataServices(apiClient, {
|
||||
apiUrl: privilegesModuleConfig.apiUrl,
|
||||
moduleKey: privilegesModuleConfig.moduleKey,
|
||||
transformer: privilegesDataTransformer,
|
||||
});
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PrivilegesRemoteDataTransformer } from './privilege.remote.transformer';
|
||||
import type { PrivilegeDetailResponseDto, PrivilegeEntity } from '../entities';
|
||||
|
||||
const transformer = new PrivilegesRemoteDataTransformer();
|
||||
|
||||
const detailDto: PrivilegeDetailResponseDto = {
|
||||
id: 'priv-1',
|
||||
name: 'Sales Staff',
|
||||
code: 'SALES_STAFF',
|
||||
status: 'active',
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000001000,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-2',
|
||||
details: [
|
||||
{
|
||||
id: 'd1',
|
||||
privilegeKeyId: 'key-1',
|
||||
keyCode: 'SALES.INVOICE',
|
||||
keyLabel: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
action: 'view',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
privilegeKeyId: 'key-1',
|
||||
keyCode: 'SALES.INVOICE',
|
||||
keyLabel: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
action: 'create',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('PrivilegesRemoteDataTransformer', () => {
|
||||
it('maps details into a matrix and defaults missing actions to false', () => {
|
||||
const entity = transformer.transformToEntity(detailDto);
|
||||
|
||||
expect(entity.matrix?.['key-1']).toEqual({
|
||||
view: true,
|
||||
create: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
import: false,
|
||||
});
|
||||
expect(entity.details?.[0]).toMatchObject({
|
||||
privilegeKeyId: 'key-1',
|
||||
keyCode: 'SALES.INVOICE',
|
||||
keyLabel: 'Sales Invoice',
|
||||
sortOrder: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds create payload with name, code, and flattened details only', () => {
|
||||
const entity: PrivilegeEntity = {
|
||||
name: 'Sales Staff',
|
||||
code: 'SALES_STAFF',
|
||||
status: 'draft',
|
||||
matrix: {
|
||||
'key-1': { view: true, create: true, update: false, delete: false, import: true },
|
||||
},
|
||||
};
|
||||
|
||||
const payload = transformer.transformCreatePayload(entity);
|
||||
|
||||
expect(payload).toEqual({
|
||||
name: 'Sales Staff',
|
||||
code: 'SALES_STAFF',
|
||||
details: [
|
||||
{ privilegeKeyId: 'key-1', action: 'view', value: true },
|
||||
{ privilegeKeyId: 'key-1', action: 'create', value: true },
|
||||
{ privilegeKeyId: 'key-1', action: 'update', value: false },
|
||||
{ privilegeKeyId: 'key-1', action: 'delete', value: false },
|
||||
{ privilegeKeyId: 'key-1', action: 'import', value: true },
|
||||
],
|
||||
});
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('builds edit payload without status', () => {
|
||||
const entity: PrivilegeEntity = {
|
||||
id: 'priv-1',
|
||||
name: 'Sales Staff',
|
||||
code: 'SALES_STAFF',
|
||||
status: 'active',
|
||||
matrix: {
|
||||
'key-1': { view: true, create: false, update: false, delete: false, import: false },
|
||||
},
|
||||
};
|
||||
|
||||
const payload = transformer.transformEditPayload(entity);
|
||||
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
expect(payload).not.toHaveProperty('id');
|
||||
expect(payload.details).toEqual(
|
||||
expect.arrayContaining([{ privilegeKeyId: 'key-1', action: 'view', value: true }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps an empty details list to an empty matrix', () => {
|
||||
const entity = transformer.transformToEntity({ ...detailDto, details: [] });
|
||||
expect(entity.matrix).toEqual({});
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import {
|
||||
EMPTY_MATRIX_CELL,
|
||||
PRIVILEGE_ACTIONS,
|
||||
PrivilegeDetailDto,
|
||||
PrivilegeDetailInputDto,
|
||||
PrivilegeDto,
|
||||
PrivilegeEntity,
|
||||
PrivilegeKeyDto,
|
||||
PrivilegeKeyEntity,
|
||||
PrivilegeMatrixCell,
|
||||
} from '../entities';
|
||||
|
||||
function emptyCell(): PrivilegeMatrixCell {
|
||||
return { ...EMPTY_MATRIX_CELL };
|
||||
}
|
||||
|
||||
export function detailsToMatrix(details: PrivilegeDetailDto[] | undefined): Record<string, PrivilegeMatrixCell> {
|
||||
if (!details?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return details.reduce<Record<string, PrivilegeMatrixCell>>((matrix, detail) => {
|
||||
const keyId = detail.privilegeKeyId;
|
||||
const current = { ...(matrix[keyId] ?? emptyCell()) };
|
||||
if (PRIVILEGE_ACTIONS.includes(detail.action)) {
|
||||
current[detail.action] = Boolean(detail.value);
|
||||
}
|
||||
return { ...matrix, [keyId]: current };
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function matrixToDetails(matrix: Record<string, PrivilegeMatrixCell> | undefined): PrivilegeDetailInputDto[] {
|
||||
if (!matrix) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(matrix).flatMap(([privilegeKeyId, cell]) =>
|
||||
PRIVILEGE_ACTIONS.map((action) => ({
|
||||
privilegeKeyId,
|
||||
action,
|
||||
value: Boolean(cell?.[action]),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function mapPrivilegeKey(dto: PrivilegeKeyDto): PrivilegeKeyEntity {
|
||||
return {
|
||||
id: String(dto.id ?? ''),
|
||||
code: String(dto.code ?? dto.keyCode ?? ''),
|
||||
label: String(dto.label ?? dto.keyLabel ?? dto.code ?? dto.keyCode ?? ''),
|
||||
sortOrder: Number(dto.sortOrder ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export class PrivilegesRemoteDataTransformer extends BaseDataTransformer<PrivilegeEntity> {
|
||||
transformToEntity(dto: PrivilegeDto | PrivilegeEntity): PrivilegeEntity {
|
||||
return {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
code: dto.code,
|
||||
status: dto.status,
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
createdBy: dto.createdBy,
|
||||
updatedBy: dto.updatedBy,
|
||||
details: (dto.details ?? []).map((detail) => ({
|
||||
id: detail.id,
|
||||
privilegeKeyId: detail.privilegeKeyId,
|
||||
keyCode: detail.keyCode ?? '',
|
||||
keyLabel: detail.keyLabel ?? '',
|
||||
sortOrder: detail.sortOrder ?? 0,
|
||||
action: detail.action,
|
||||
value: Boolean(detail.value),
|
||||
})),
|
||||
matrix: detailsToMatrix((dto as PrivilegeDto).details),
|
||||
};
|
||||
}
|
||||
|
||||
transformToDTO(entity: PrivilegeEntity): PrivilegeEntity {
|
||||
return {
|
||||
...entity,
|
||||
details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
|
||||
};
|
||||
}
|
||||
|
||||
transformCreatePayload(entity: Partial<PrivilegeEntity>): Partial<PrivilegeEntity> {
|
||||
return {
|
||||
name: entity.name,
|
||||
code: entity.code,
|
||||
details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
|
||||
};
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<PrivilegeEntity>): Partial<PrivilegeEntity> {
|
||||
return {
|
||||
name: entity.name,
|
||||
code: entity.code,
|
||||
details: matrixToDetails(entity.matrix) as PrivilegeEntity['details'],
|
||||
};
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPrivilegeSchema } from './privilege.validator';
|
||||
|
||||
describe('createPrivilegeSchema', () => {
|
||||
const t = (key: string) => key;
|
||||
const schema = createPrivilegeSchema(t);
|
||||
|
||||
it('rejects empty name and code', () => {
|
||||
const result = schema.safeParse({ name: '', code: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a name longer than 120 characters', () => {
|
||||
const result = schema.safeParse({ name: 'A'.repeat(121), code: 'SALES_STAFF' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a code longer than 64 characters', () => {
|
||||
const result = schema.safeParse({ name: 'Sales Staff', code: 'A'.repeat(65) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a valid name and code', () => {
|
||||
const result = schema.safeParse({ name: 'Sales Staff', code: 'SALES_STAFF' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an optional matrix', () => {
|
||||
const result = schema.safeParse({
|
||||
name: 'Sales Staff',
|
||||
code: 'SALES_STAFF',
|
||||
matrix: { 'key-1': { view: true, create: false, update: false, delete: false, import: false } },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, required, maxLength } from '@repo/ui/validators';
|
||||
|
||||
export const createPrivilegeSchema = (t: (key: string) => string) => {
|
||||
return z.object({
|
||||
name: compose(z.string(), required(t('common:fields.name')), maxLength(120, t('common:fields.name'))),
|
||||
code: compose(z.string(), required(t('common:fields.code')), maxLength(64, t('common:fields.code'))),
|
||||
matrix: z
|
||||
.record(
|
||||
z.object({
|
||||
view: z.boolean().optional(),
|
||||
create: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
import: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import type { PrivilegeEntity } from '../../../domain/entities';
|
||||
|
||||
export function DetailGeneral() {
|
||||
const { detailData } = useDetailPageContext<PrivilegeEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={data?.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.createdAt')}
|
||||
value={data?.createdAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.updatedAt')}
|
||||
value={data?.updatedAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../../domain/entities';
|
||||
|
||||
export function DetailPermissions() {
|
||||
const { detailData } = useDetailPageContext<PrivilegeEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const matrix = detailData?.matrix ?? {};
|
||||
const details = detailData?.details ?? [];
|
||||
|
||||
const rows = Object.entries(matrix)
|
||||
.map(([keyId, cell]) => {
|
||||
const sample = details.find((detail) => detail.privilegeKeyId === keyId);
|
||||
return {
|
||||
keyId,
|
||||
code: sample?.keyCode ?? keyId,
|
||||
label: sample?.keyLabel ?? keyId,
|
||||
sortOrder: sample?.sortOrder ?? 0,
|
||||
cell: cell ?? EMPTY_MATRIX_CELL,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_permissions')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Box, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
|
||||
export function FormGeneral() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="name"
|
||||
label={t('common:fields.name')}
|
||||
placeholder="e.g. Sales Staff"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
name="code"
|
||||
control={formControl.control}
|
||||
label={t('common:fields.code')}
|
||||
placeholder="e.g. SALES_STAFF"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { FieldCheckbox } from '@repo/ui/form';
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { privilegesDataService } from '../../../domain/factories';
|
||||
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeKeyEntity } from '../../../domain/entities';
|
||||
|
||||
export function FormPermissions() {
|
||||
const { formControl } = useFormPageContext();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const [keys, setKeys] = useState<PrivilegeKeyEntity[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
privilegesDataService.listPrivilegeKeys().then((response) => {
|
||||
if (cancelled) return;
|
||||
const rows = [...(response.data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
setKeys(rows);
|
||||
rows.forEach((key) => {
|
||||
const current = formControl.getValues(`matrix.${key.id}`);
|
||||
if (!current) {
|
||||
formControl.setValue(`matrix.${key.id}`, { ...EMPTY_MATRIX_CELL });
|
||||
}
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [formControl]);
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_permissions')}
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { SimpleGrid } from '@repo/ui/components';
|
||||
import { FieldTextInput, FieldSelect } from '@repo/ui/form';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
placeholder={`Enter ${t('common:fields.code')}`}
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="name"
|
||||
label={t('common:fields.name')}
|
||||
placeholder={`Enter ${t('common:fields.name')}`}
|
||||
/>
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={[
|
||||
{ value: 'draft', label: t('status_draft') },
|
||||
{ value: 'active', label: t('status_active') },
|
||||
{ value: 'archived', label: t('status_archived') },
|
||||
]}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { privilegesModuleConfig } from '../../domain/constants';
|
||||
import { privilegesDataService } from '../../domain/factories';
|
||||
import { PrivilegeEntity } from '../../domain/entities';
|
||||
import { privilegesStore } from '../store';
|
||||
|
||||
import privilegesId from '../languages/id/privileges.json';
|
||||
import privilegesEn from '../languages/en/privileges.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/privilege.page.index'));
|
||||
const FormPage = lazy(() => import('../pages/privilege.page.form'));
|
||||
const DetailPage = lazy(() => import('../pages/privilege.page.detail'));
|
||||
|
||||
registerModuleNamespace(privilegesModuleConfig.translationNamespace, {
|
||||
id: privilegesId,
|
||||
en: privilegesEn,
|
||||
});
|
||||
|
||||
export default function PrivilegesModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider<PrivilegeEntity>
|
||||
config={privilegesModuleConfig}
|
||||
dataServices={privilegesDataService}
|
||||
store={privilegesStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${privilegesModuleConfig.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "Privileges",
|
||||
"detail_page_title": "Privilege Detail",
|
||||
"create_page_title": "New Privilege",
|
||||
"edit_page_title": "Edit Privilege",
|
||||
"duplicate_page_title": "Duplicate Privilege",
|
||||
"description": "Manage role templates and the <1>permission matrix</1> for each module.",
|
||||
"detail_page_description": "Review the privilege profile and its module permissions.",
|
||||
"create_page_description": "Create a privilege and choose which actions each module can perform.",
|
||||
"edit_page_description": "Update the privilege name, code, and permission matrix.",
|
||||
"duplicate_page_description": "Copy an existing privilege to create a new role template.",
|
||||
"section_general": "General",
|
||||
"section_permissions": "Permissions",
|
||||
"matrix_module": "Module",
|
||||
"matrix_view": "View",
|
||||
"matrix_create": "Create",
|
||||
"matrix_update": "Update",
|
||||
"matrix_delete": "Delete",
|
||||
"matrix_import": "Import",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Active",
|
||||
"status_archived": "Archived"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "Hak Akses",
|
||||
"detail_page_title": "Detail Hak Akses",
|
||||
"create_page_title": "Hak Akses Baru",
|
||||
"edit_page_title": "Ubah Hak Akses",
|
||||
"duplicate_page_title": "Duplikat Hak Akses",
|
||||
"description": "Kelola templat peran dan <1>matriks izin</1> untuk setiap modul.",
|
||||
"detail_page_description": "Tinjau profil hak akses dan izin modulnya.",
|
||||
"create_page_description": "Buat hak akses dan pilih aksi yang boleh dilakukan setiap modul.",
|
||||
"edit_page_description": "Perbarui nama, kode, dan matriks izin hak akses.",
|
||||
"duplicate_page_description": "Salin hak akses yang ada untuk membuat templat peran baru.",
|
||||
"section_general": "Umum",
|
||||
"section_permissions": "Izin",
|
||||
"matrix_module": "Modul",
|
||||
"matrix_view": "Lihat",
|
||||
"matrix_create": "Buat",
|
||||
"matrix_update": "Ubah",
|
||||
"matrix_delete": "Hapus",
|
||||
"matrix_import": "Impor",
|
||||
"status_draft": "Draft",
|
||||
"status_active": "Aktif",
|
||||
"status_archived": "Diarsipkan"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { privilegesModuleConfig } from '../../domain/constants';
|
||||
import { DetailGeneral } from '../components/detail-component/detail-general';
|
||||
import { DetailPermissions } from '../components/detail-component/detail-permissions';
|
||||
|
||||
export default function PrivilegePageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
return (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:system'), type: 'text' },
|
||||
{ label: t('nav:system-privileges'), type: 'link', href: `${privilegesModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral />
|
||||
<DetailPermissions />
|
||||
</Stack>
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { privilegesModuleConfig } from '../../domain/constants';
|
||||
import { createPrivilegeSchema } from '../../domain/validators/privilege.validator';
|
||||
import { FormGeneral } from '../components/form-component/form-general';
|
||||
import { FormPermissions } from '../components/form-component/form-permissions';
|
||||
|
||||
export default function PrivilegePageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (formPageType === 'CREATE') {
|
||||
return { title: t('create_page_title'), description: t('create_page_description') };
|
||||
}
|
||||
if (formPageType === 'EDIT') {
|
||||
return { title: t('edit_page_title'), description: t('edit_page_description') };
|
||||
}
|
||||
if (formPageType === 'DUPLICATE') {
|
||||
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
|
||||
}
|
||||
return { title: '', description: '' };
|
||||
}, [formPageType, t]);
|
||||
|
||||
const validator = useMemo(() => createPrivilegeSchema(t), [t]);
|
||||
const formControl = useForm({ resolver: zodResolver(validator) });
|
||||
|
||||
return (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['code']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'details', 'id']}
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:system'), type: 'text' },
|
||||
{ label: t('nav:system-privileges'), type: 'link', href: `${privilegesModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<FormGeneral />
|
||||
<FormPermissions />
|
||||
</Stack>
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
EnterpriseIndexPageProvider,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
EnterpriseDataTable,
|
||||
} from '@repo/ui/foundations';
|
||||
import { ColDef, Text } from '@repo/ui/components';
|
||||
import { Trans } from '@repo/core-i18n';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||
import type { PrivilegeEntity } from '../../domain/entities';
|
||||
|
||||
export default function PrivilegePageIndex() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
|
||||
const columnDefs: ColDef<PrivilegeEntity>[] = useMemo(() => {
|
||||
return [
|
||||
{ field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
|
||||
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const filterConfig = useMemo(() => {
|
||||
return {
|
||||
renderBody: (form: any) => {
|
||||
if (!form) return null;
|
||||
return <FilterFormContent form={form} t={t} />;
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title'),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: Shield,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:system'), type: 'text' },
|
||||
{ label: t('nav:system-privileges'), type: 'text' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { PrivilegeEntity } from '../../domain/entities';
|
||||
|
||||
export interface PrivilegesStoreState extends EnterpriseModuleState<PrivilegeEntity> {}
|
||||
|
||||
export const privilegesStore = create<PrivilegesStoreState>((set) => ({
|
||||
metaData: { limit: 15 },
|
||||
setMetaData: (data) => set({ metaData: data }),
|
||||
|
||||
filterData: {},
|
||||
setFilterData: (data) => set({ filterData: data }),
|
||||
|
||||
selectedRows: [],
|
||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||
|
||||
privileges: [],
|
||||
setPrivileges: (privileges) => set({ privileges }),
|
||||
|
||||
tableConfig: null,
|
||||
setTableConfig: (config) => set({ tableConfig: config }),
|
||||
}));
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { noPrivileges } from '@repo/ui/foundations';
|
||||
import { filterMenuByViewPrivilege } from './filter-menu-by-view-privilege';
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'system',
|
||||
children: [
|
||||
{ key: 'privileges', moduleKey: 'PRIVILEGES', path: '/app/system/privileges/index' },
|
||||
{ key: 'placeholder', path: '/app/system/other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'example',
|
||||
children: [{ key: 'full-page', moduleKey: 'EXAMPLE_FULL_PAGE', path: '/app/example/full-page/index' }],
|
||||
},
|
||||
{ key: 'dashboard', path: '/app/dashboard' },
|
||||
];
|
||||
|
||||
describe('filterMenuByViewPrivilege', () => {
|
||||
it('returns the tree unchanged for a superadmin', () => {
|
||||
expect(filterMenuByViewPrivilege(items, {}, true)).toEqual(items);
|
||||
});
|
||||
|
||||
it('hides a leaf when moduleKey is present and ALLOW_VIEW is false', () => {
|
||||
const filtered = filterMenuByViewPrivilege(
|
||||
items,
|
||||
{
|
||||
PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: false },
|
||||
EXAMPLE_FULL_PAGE: { ...noPrivileges, ALLOW_VIEW: true },
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(filtered.find((item) => item.key === 'system')?.children).toEqual([
|
||||
{ key: 'placeholder', path: '/app/system/other' },
|
||||
]);
|
||||
expect(filtered.find((item) => item.key === 'example')?.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a parent when every child is removed', () => {
|
||||
const filtered = filterMenuByViewPrivilege(
|
||||
items,
|
||||
{
|
||||
PRIVILEGES: { ...noPrivileges },
|
||||
EXAMPLE_FULL_PAGE: { ...noPrivileges },
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(filtered.find((item) => item.key === 'example')).toBeUndefined();
|
||||
expect(filtered.find((item) => item.key === 'system')?.children).toEqual([
|
||||
{ key: 'placeholder', path: '/app/system/other' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a leaf without moduleKey', () => {
|
||||
const filtered = filterMenuByViewPrivilege(items, {}, false);
|
||||
expect(filtered.find((item) => item.key === 'dashboard')).toEqual({ key: 'dashboard', path: '/app/dashboard' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||
|
||||
export function filterMenuByViewPrivilege<T extends { moduleKey?: string; children?: T[] }>(
|
||||
items: T[],
|
||||
privileges: Record<string, PrivilegeEntity>,
|
||||
isSuperadmin: boolean,
|
||||
): T[] {
|
||||
if (isSuperadmin) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.flatMap((item) => {
|
||||
const children = item.children
|
||||
? filterMenuByViewPrivilege(item.children, privileges, false)
|
||||
: undefined;
|
||||
|
||||
if (item.moduleKey && privileges[item.moduleKey]?.ALLOW_VIEW !== true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (children) {
|
||||
if (children.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [{ ...item, children }];
|
||||
}
|
||||
|
||||
return [item];
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { noPrivileges } from '@repo/ui/foundations';
|
||||
import { mapUserPrivileges } from './map-user-privileges';
|
||||
|
||||
describe('mapUserPrivileges', () => {
|
||||
it('maps view/create/update/delete and ignores extra API flags', () => {
|
||||
it('maps view/create/update/delete/import flags', () => {
|
||||
const result = mapUserPrivileges(
|
||||
{
|
||||
PRIVILEGES: {
|
||||
@@ -23,6 +23,7 @@ describe('mapUserPrivileges', () => {
|
||||
ALLOW_CREATE: true,
|
||||
ALLOW_EDIT: false,
|
||||
ALLOW_DELETE: false,
|
||||
ALLOW_IMPORT: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,5 +21,8 @@ function mapPermissionFlags(flags: AuthPermissionFlags = {}): PrivilegeEntity {
|
||||
ALLOW_CREATE: flags.create ?? false,
|
||||
ALLOW_EDIT: flags.update ?? false,
|
||||
ALLOW_DELETE: flags.delete ?? false,
|
||||
ALLOW_IMPORT: flags.import ?? false,
|
||||
ALLOW_ACTIVATE: flags.update ?? false,
|
||||
ALLOW_DEACTIVATE: flags.update ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||
import { appDatabase, AppDatabaseKey } from '../storage/local';
|
||||
import { filterMenuByViewPrivilege } from './filter-menu-by-view-privilege';
|
||||
|
||||
export function useFilteredMenuItems<T extends { moduleKey?: string; children?: T[] }>(items: T[]): T[] {
|
||||
const [filtered, setFiltered] = useState(items);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
|
||||
const privileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
|
||||
if (cancelled) return;
|
||||
setFiltered(filterMenuByViewPrivilege(items, privileges ?? {}, Boolean(profile?.isSuperadmin)));
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export enum STATUS_DATA {
|
||||
|
||||
INACTIVE = 'inactive',
|
||||
DEACTIVATED = 'deactivated',
|
||||
ARCHIVED = 'archived',
|
||||
|
||||
REQUEST = 'request',
|
||||
REQUESTING = 'requesting',
|
||||
@@ -198,6 +199,7 @@ export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = {
|
||||
[STATUS_DATA.TODO]: { color: STATUS_COLOR.TODO, leftSection: getIcon(Clock) },
|
||||
|
||||
[STATUS_DATA.INACTIVE]: { color: STATUS_COLOR.INACTIVE, leftSection: getIcon(XCircle) },
|
||||
[STATUS_DATA.ARCHIVED]: { color: STATUS_COLOR.INACTIVE, leftSection: getIcon(XCircle) },
|
||||
[STATUS_DATA.DEACTIVATED]: { color: STATUS_COLOR.DEACTIVATED, leftSection: getIcon(XCircle) },
|
||||
[STATUS_DATA.CLOSE]: { color: STATUS_COLOR.CLOSE, leftSection: getIcon(XCircle) },
|
||||
[STATUS_DATA.CLOSED]: { color: STATUS_COLOR.CLOSED, leftSection: getIcon(XCircle) },
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export function BulkActionMenu({
|
||||
|
||||
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
|
||||
const hasInactive = selectedRows.some(
|
||||
(row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft',
|
||||
(row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft' || row[statusKey]?.toLowerCase() === 'archived',
|
||||
);
|
||||
|
||||
const defaultActions: PageActionProps[] = [];
|
||||
|
||||
@@ -6,6 +6,7 @@ export const defaultPrivileges: PrivilegeEntity = {
|
||||
ALLOW_CREATE: true,
|
||||
ALLOW_EDIT: true,
|
||||
ALLOW_DELETE: true,
|
||||
ALLOW_IMPORT: true,
|
||||
|
||||
ALLOW_PRINT: true,
|
||||
ALLOW_PRINT_COPY: true,
|
||||
@@ -29,6 +30,7 @@ export const noPrivileges: PrivilegeEntity = {
|
||||
ALLOW_CREATE: false,
|
||||
ALLOW_EDIT: false,
|
||||
ALLOW_DELETE: false,
|
||||
ALLOW_IMPORT: false,
|
||||
|
||||
ALLOW_PRINT: false,
|
||||
ALLOW_PRINT_COPY: false,
|
||||
|
||||
@@ -299,6 +299,7 @@ export interface PrivilegeEntity {
|
||||
ALLOW_CREATE: boolean;
|
||||
ALLOW_EDIT: boolean;
|
||||
ALLOW_DELETE: boolean;
|
||||
ALLOW_IMPORT: boolean;
|
||||
|
||||
ALLOW_PRINT: boolean;
|
||||
ALLOW_PRINT_COPY: boolean;
|
||||
|
||||
@@ -504,7 +504,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
const isMasterData = moduleType === 'MASTER_DATA';
|
||||
|
||||
const isDataActive = detailData && ['active'].includes(detailData[statusKey]?.toLowerCase());
|
||||
const isDataInActive = detailData && ['inactive', 'draft'].includes(detailData[statusKey]?.toLowerCase());
|
||||
const isDataInActive = detailData && ['inactive', 'draft', 'archived'].includes(detailData[statusKey]?.toLowerCase());
|
||||
|
||||
// 1. Declare action with Privilege & Module Type conditions directly
|
||||
const rawActions = [
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '../hooks/use-module.context';
|
||||
import { StatusPage } from '../../../components';
|
||||
import { useEnterpriseStorage } from '../hooks/enterprise-storage.context';
|
||||
import { defaultPrivileges, noPrivileges } from '../constant';
|
||||
import { noPrivileges } from '../constant';
|
||||
|
||||
export interface EnterpriseModuleProviderProps<
|
||||
E extends BaseEntity,
|
||||
@@ -47,7 +47,8 @@ export function EnterpriseModuleProvider<
|
||||
// Config Slice (Static)
|
||||
// ---------------------------------------------------------------------------
|
||||
const storage = useEnterpriseStorage();
|
||||
const [parsedPrivileges, setParsedPrivileges] = useState<PrivilegeEntity>(defaultPrivileges);
|
||||
const [arePrivilegesReady, setArePrivilegesReady] = useState(false);
|
||||
const [parsedPrivileges, setParsedPrivileges] = useState<PrivilegeEntity>(noPrivileges);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPrivilegeData = async () => {
|
||||
@@ -63,6 +64,8 @@ export function EnterpriseModuleProvider<
|
||||
} catch (error) {
|
||||
setParsedPrivileges(noPrivileges);
|
||||
console.error('Failed to retrieve privilege data:', error);
|
||||
} finally {
|
||||
setArePrivilegesReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,6 +179,10 @@ export function EnterpriseModuleProvider<
|
||||
|
||||
const { ALLOW_VIEW } = configSlice.privileges ?? {};
|
||||
|
||||
if (!arePrivilegesReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ALLOW_VIEW) {
|
||||
return (
|
||||
<StatusPage
|
||||
|
||||
Reference in New Issue
Block a user