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:
shancheas
2026-08-25 19:06:50 +07:00
parent 67ae5b6c11
commit a5a42cf87e
40 changed files with 1271 additions and 11 deletions
+2
View File
@@ -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;
}
@@ -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';
@@ -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,
});
@@ -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({});
});
});
@@ -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'],
};
}
}
@@ -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);
});
});
@@ -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(),
});
};
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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"
}
@@ -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"
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
}