@epcare/esm-report-builder 0.1.1-pre.76 → 0.1.1-pre.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__mocks__/react-i18next.js +12 -0
- package/dist/729.js +1 -1
- package/dist/760.js +1 -0
- package/dist/760.js.map +1 -0
- package/dist/main.js +1 -1
- package/dist/openmrs-esm-report-builder.js.buildmanifest.json +26 -26
- package/dist/routes.json +1 -1
- package/package.json +1 -1
- package/routes.json +3 -2
- package/src/components/admin/admin-page.component.tsx +53 -24
- package/src/components/admin/etl-browser/etl-browser.component.scss +10 -3
- package/src/components/admin/etl-browser/preview-grid.component.tsx +1 -1
- package/src/components/admin/etl-sources-page.component.tsx +30 -19
- package/src/components/admin/report-categories-page.component.tsx +32 -21
- package/src/components/admin/report-library-page.component.tsx +30 -19
- package/src/components/app-shell/report-builder-shell.component.tsx +194 -162
- package/src/components/app-shell/report-builder-shell.scss +4 -1
- package/src/components/dashboards/dashboards-page.component.tsx +13 -6
- package/src/components/dashboards/dashboards-table.component.tsx +24 -17
- package/src/components/data-themes/data-themes-page.component.tsx +11 -4
- package/src/components/data-themes/data-themes-table.component.tsx +10 -3
- package/src/components/data-visualizer/data-visualizer.resource.ts +49 -37
- package/src/components/etl-monitor/builder/etl-monitor-builder-page.component.tsx +6 -1
- package/src/components/etl-monitor/etl-monitors-page.component.tsx +35 -24
- package/src/components/import-export/import-export-page.component.tsx +38 -22
- package/src/components/indicators/indicators-page.component.tsx +41 -6
- package/src/components/indicators/indicators-table.component.tsx +24 -4
- package/src/components/landing/report-builder-landing-page.component.tsx +34 -1
- package/src/components/linelist/definition/linelist-definition-editor.component.tsx +14 -2
- package/src/components/linelist/linelist-dashboard.page.component.tsx +20 -13
- package/src/components/report/report-dashboard.page.component.tsx +14 -8
- package/src/components/report/report-editor.page.component.tsx +6 -1
- package/src/components/report-sections/report-sections-page.component.tsx +17 -5
- package/src/components/security/access-denied-page.component.tsx +69 -0
- package/src/components/security/access-denied-page.scss +39 -0
- package/src/components/security/privilege-route.component.test.tsx +79 -0
- package/src/components/security/privilege-route.component.tsx +39 -0
- package/src/constants/privileges.test.ts +53 -0
- package/src/constants/privileges.ts +118 -0
- package/src/hooks/use-report-builder-privileges.test.tsx +98 -0
- package/src/hooks/use-report-builder-privileges.ts +58 -0
- package/src/resources/etl-monitor/etl-monitor-test.api.ts +5 -2
- package/src/resources/etl-task/etl-task.api.ts +21 -13
- package/src/resources/legacy-reports/legacy-reports.api.ts +10 -22
- package/src/resources/openmrs-api.ts +30 -14
- package/src/resources/report-section/section-preview.api.ts +10 -6
- package/src/routes/report-builder.routes.tsx +93 -28
- package/src/routes.json +3 -2
- package/src/utils/api-error.utils.test.ts +151 -0
- package/src/utils/api-error.utils.ts +131 -0
- package/dist/359.js +0 -1
- package/dist/359.js.map +0 -1
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
parseLinelistConfig,
|
|
50
50
|
} from '../../resources/linelist/linelist-reports.api';
|
|
51
51
|
import { listReportCategories, type ReportCategoryDto } from '../../resources/report-category/report-category.api';
|
|
52
|
+
import { RB } from '../../constants/privileges';
|
|
53
|
+
import { useReportBuilderPrivileges } from '../../hooks/use-report-builder-privileges';
|
|
52
54
|
import { listDataThemes, type DataThemeDto } from '../../resources/theme/data-theme.api';
|
|
53
55
|
import type { LinelistRowGrain } from '../../types/linelist-types';
|
|
54
56
|
|
|
@@ -74,6 +76,8 @@ interface ReportWithMetadata extends LinelistReportDefinitionDto {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const LinelistReportsPage: React.FC<Props> = () => {
|
|
79
|
+
const { has: hasPrivilege } = useReportBuilderPrivileges();
|
|
80
|
+
const canCreateLinelist = hasPrivilege(RB.REPORT_ADD, RB.REPORT_EDIT);
|
|
77
81
|
const navigate = useNavigate();
|
|
78
82
|
|
|
79
83
|
const [reports, setReports] = useState<ReportWithMetadata[]>([]);
|
|
@@ -493,14 +497,16 @@ const LinelistReportsPage: React.FC<Props> = () => {
|
|
|
493
497
|
|
|
494
498
|
{/* Action buttons */}
|
|
495
499
|
<div className={styles.filterActions}>
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
500
|
+
{canCreateLinelist && (
|
|
501
|
+
<Button
|
|
502
|
+
kind="ghost"
|
|
503
|
+
size="md"
|
|
504
|
+
renderIcon={Add}
|
|
505
|
+
onClick={() => navigate('/linelist/new')}
|
|
506
|
+
>
|
|
507
|
+
Create linelist report
|
|
508
|
+
</Button>
|
|
509
|
+
)}
|
|
504
510
|
{selectedReports.size > 0 && (
|
|
505
511
|
<Button
|
|
506
512
|
kind="primary"
|
|
@@ -590,11 +596,12 @@ const LinelistReportsPage: React.FC<Props> = () => {
|
|
|
590
596
|
? 'No reports match your filters. Try adjusting your search criteria.'
|
|
591
597
|
: 'Get started by creating your first linelist report.'}
|
|
592
598
|
</p>
|
|
593
|
-
{!(searchQuery || statusFilter !== 'all' || categoryFilter || rowTypeFilter !== 'all') &&
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
599
|
+
{!(searchQuery || statusFilter !== 'all' || categoryFilter || rowTypeFilter !== 'all') &&
|
|
600
|
+
canCreateLinelist && (
|
|
601
|
+
<Button kind="primary" renderIcon={Add} onClick={() => navigate('/linelist/new')}>
|
|
602
|
+
Create Linelist Report
|
|
603
|
+
</Button>
|
|
604
|
+
)}
|
|
598
605
|
</Tile>
|
|
599
606
|
) : (
|
|
600
607
|
<>
|
|
@@ -31,6 +31,8 @@ import { Play, Add, Renew } from '@carbon/react/icons';
|
|
|
31
31
|
|
|
32
32
|
import Header from '../shared/header/header.component';
|
|
33
33
|
import { listReports, compileReport, type ReportDto } from '../../resources/report/reports.api';
|
|
34
|
+
import { RB } from '../../constants/privileges';
|
|
35
|
+
import { useReportBuilderPrivileges } from '../../hooks/use-report-builder-privileges';
|
|
34
36
|
import { listReportCategories, type ReportCategoryDto } from '../../resources/report-category/report-category.api';
|
|
35
37
|
import ExportReportModal from '../import-export/export-report-modal.component';
|
|
36
38
|
|
|
@@ -44,6 +46,8 @@ type ReportWithMetadata = ReportDto & {
|
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
const ReportDashboardPage: React.FC = () => {
|
|
49
|
+
const { has: hasPrivilege } = useReportBuilderPrivileges();
|
|
50
|
+
const canCreateReport = hasPrivilege(RB.REPORT_ADD, RB.REPORT_EDIT);
|
|
47
51
|
const { t } = useTranslation();
|
|
48
52
|
const navigate = useNavigate();
|
|
49
53
|
|
|
@@ -362,14 +366,16 @@ const ReportDashboardPage: React.FC = () => {
|
|
|
362
366
|
Compile Selected ({selectedReports.size})
|
|
363
367
|
</Button>
|
|
364
368
|
)}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
369
|
+
{canCreateReport && (
|
|
370
|
+
<Button
|
|
371
|
+
kind="ghost"
|
|
372
|
+
size="md"
|
|
373
|
+
renderIcon={Add}
|
|
374
|
+
onClick={() => navigate('/new')}
|
|
375
|
+
>
|
|
376
|
+
Create Report
|
|
377
|
+
</Button>
|
|
378
|
+
)}
|
|
373
379
|
<Button
|
|
374
380
|
kind="ghost"
|
|
375
381
|
size="md"
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
import { listSections } from '../../resources/report-section/report-sections.api';
|
|
35
35
|
import { listReportCategories, type ReportCategoryDto } from '../../resources/report-category/report-category.api';
|
|
36
36
|
import CompileSetupModal, { type CompileSetupResult } from '../shared/compile-setup-modal.component';
|
|
37
|
+
import { RB } from '../../constants/privileges';
|
|
38
|
+
import { useReportBuilderPrivileges } from '../../hooks/use-report-builder-privileges';
|
|
37
39
|
|
|
38
40
|
type TabKey = 'details' | 'definition' | 'design';
|
|
39
41
|
type BuilderMode = 'create' | 'edit';
|
|
@@ -118,6 +120,7 @@ function safeParseJson(raw?: string | null): any {
|
|
|
118
120
|
export default function ReportEditorPage() {
|
|
119
121
|
const { t } = useTranslation();
|
|
120
122
|
const { reportId } = useParams();
|
|
123
|
+
const { has: hasPrivilege } = useReportBuilderPrivileges();
|
|
121
124
|
|
|
122
125
|
const mode: BuilderMode = reportId ? 'edit' : 'create';
|
|
123
126
|
|
|
@@ -448,7 +451,9 @@ export default function ReportEditorPage() {
|
|
|
448
451
|
}, []);
|
|
449
452
|
|
|
450
453
|
const canSave = Boolean(form.name.trim()) && !saving && !loading;
|
|
451
|
-
const canCompile =
|
|
454
|
+
const canCompile =
|
|
455
|
+
hasPrivilege(RB.REPORT_COMPILE) &&
|
|
456
|
+
Boolean((savedReport?.uuid || reportId || form.name.trim()) && !saving && !compiling && !loading);
|
|
452
457
|
|
|
453
458
|
return (
|
|
454
459
|
<>
|
|
@@ -18,6 +18,8 @@ import { useTranslation } from 'react-i18next';
|
|
|
18
18
|
|
|
19
19
|
import Header from '../shared/header/header.component';
|
|
20
20
|
import CreateSectionModal from './create-report-section-modal.component';
|
|
21
|
+
import { RB } from '../../constants/privileges';
|
|
22
|
+
import { useReportBuilderPrivileges } from '../../hooks/use-report-builder-privileges';
|
|
21
23
|
import type { CreateSectionPayload, SectionIndicatorRef } from './section-types';
|
|
22
24
|
import SectionPreviewModal from './report-section-preview-modal.component';
|
|
23
25
|
import styles from './sections-page.scss';
|
|
@@ -41,6 +43,10 @@ function countIndicatorsFromConfig(section: ReportSectionDto): number {
|
|
|
41
43
|
const SectionsPage: React.FC = () => {
|
|
42
44
|
const { t } = useTranslation();
|
|
43
45
|
|
|
46
|
+
const { has: hasPrivilege } = useReportBuilderPrivileges();
|
|
47
|
+
const canAddSection = hasPrivilege(RB.SECTION_ADD, RB.SECTION_EDIT);
|
|
48
|
+
const canPreviewSection = hasPrivilege(RB.SQL_EXECUTE);
|
|
49
|
+
|
|
44
50
|
const [q, setQ] = React.useState('');
|
|
45
51
|
const [openCreate, setOpenCreate] = React.useState(false);
|
|
46
52
|
|
|
@@ -193,9 +199,11 @@ const SectionsPage: React.FC = () => {
|
|
|
193
199
|
<div className={styles.sectionsPage}>
|
|
194
200
|
<div className={styles.headerRow}>
|
|
195
201
|
<h3 className={styles.title}>{t('sections', 'Sections')}</h3>
|
|
196
|
-
|
|
197
|
-
{
|
|
198
|
-
|
|
202
|
+
{canAddSection && (
|
|
203
|
+
<Button size="sm" kind="primary" renderIcon={Add} onClick={() => setOpenCreate(true)}>
|
|
204
|
+
{t('createSection', 'Create Section')}
|
|
205
|
+
</Button>
|
|
206
|
+
)}
|
|
199
207
|
</div>
|
|
200
208
|
|
|
201
209
|
<div className={styles.surface}>
|
|
@@ -238,8 +246,12 @@ const SectionsPage: React.FC = () => {
|
|
|
238
246
|
return (
|
|
239
247
|
<TableCell key={cell.id}>
|
|
240
248
|
<OverflowMenu data-testid={`section-actions-${row.id}`} size="sm" ariaLabel="Actions" flipped>
|
|
241
|
-
|
|
242
|
-
|
|
249
|
+
{canPreviewSection && (
|
|
250
|
+
<OverflowMenuItem itemText="Preview" onClick={() => openPreview(row.id)} />
|
|
251
|
+
)}
|
|
252
|
+
{canAddSection && (
|
|
253
|
+
<OverflowMenuItem itemText="Edit" onClick={() => openEdit(row.id)} />
|
|
254
|
+
)}
|
|
243
255
|
</OverflowMenu>
|
|
244
256
|
</TableCell>
|
|
245
257
|
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Button, InlineNotification, Tag } from '@carbon/react';
|
|
4
|
+
import { useNavigate } from 'react-router-dom';
|
|
5
|
+
|
|
6
|
+
import { rbDisplay, RB } from '../../constants/privileges';
|
|
7
|
+
import { useReportBuilderPrivileges } from '../../hooks/use-report-builder-privileges';
|
|
8
|
+
import styles from './access-denied-page.scss';
|
|
9
|
+
|
|
10
|
+
interface AccessDeniedPageProps {
|
|
11
|
+
/** Full privilege names, e.g. `Task: reportbuilder.report.edit`. */
|
|
12
|
+
requiredPrivileges?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const AccessDeniedPage: React.FC<AccessDeniedPageProps> = ({ requiredPrivileges }) => {
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
const navigate = useNavigate();
|
|
18
|
+
const { has } = useReportBuilderPrivileges();
|
|
19
|
+
const privileges = requiredPrivileges ?? [];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className={styles.container}>
|
|
23
|
+
<InlineNotification
|
|
24
|
+
lowContrast
|
|
25
|
+
kind="error"
|
|
26
|
+
title={t('accessDenied', 'Access denied')}
|
|
27
|
+
subtitle={t(
|
|
28
|
+
'accessDeniedSubtitle',
|
|
29
|
+
'Your account does not have permission to open this page.',
|
|
30
|
+
)}
|
|
31
|
+
hideCloseButton
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
{privileges.length > 0 && (
|
|
35
|
+
<div className={styles.section}>
|
|
36
|
+
<p className={styles.label}>
|
|
37
|
+
{t('requiredPrivileges', 'Required privilege(s):')}
|
|
38
|
+
</p>
|
|
39
|
+
<div className={styles.tags}>
|
|
40
|
+
{privileges.map((privilege) => (
|
|
41
|
+
<Tag key={privilege} type="red">
|
|
42
|
+
{rbDisplay(privilege)}
|
|
43
|
+
</Tag>
|
|
44
|
+
))}
|
|
45
|
+
</div>
|
|
46
|
+
<p className={styles.hint}>
|
|
47
|
+
{t(
|
|
48
|
+
'accessDeniedHint',
|
|
49
|
+
'Ask your administrator to grant you the report builder role carrying these privileges.',
|
|
50
|
+
)}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
|
|
55
|
+
<div className={styles.actions}>
|
|
56
|
+
{has(RB.REPORT_VIEW) && (
|
|
57
|
+
<Button kind="primary" onClick={() => navigate('/')}>
|
|
58
|
+
{t('goToHome', 'Go to Home')}
|
|
59
|
+
</Button>
|
|
60
|
+
)}
|
|
61
|
+
<Button kind="secondary" onClick={() => navigate(-1)}>
|
|
62
|
+
{t('goBack', 'Go back')}
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default AccessDeniedPage;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
@use '@carbon/styles/scss/spacing';
|
|
2
|
+
@use '@carbon/type' as type;
|
|
3
|
+
|
|
4
|
+
.container {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: spacing.$spacing-06;
|
|
8
|
+
max-width: 40rem;
|
|
9
|
+
padding: spacing.$spacing-07 spacing.$spacing-06;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.section {
|
|
13
|
+
display: flex;
|
|
14
|
+
flex-direction: column;
|
|
15
|
+
gap: spacing.$spacing-03;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.label {
|
|
19
|
+
@include type.type-style('body-01');
|
|
20
|
+
|
|
21
|
+
color: var(--cds-text-secondary);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.tags {
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-wrap: wrap;
|
|
27
|
+
gap: spacing.$spacing-02;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.hint {
|
|
31
|
+
@include type.type-style('body-01');
|
|
32
|
+
|
|
33
|
+
color: var(--cds-text-secondary);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.actions {
|
|
37
|
+
display: flex;
|
|
38
|
+
gap: spacing.$spacing-03;
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { act } from 'react-dom/test-utils';
|
|
4
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
5
|
+
|
|
6
|
+
jest.mock('@openmrs/esm-framework', () => ({
|
|
7
|
+
useSession: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { useSession } from '@openmrs/esm-framework';
|
|
11
|
+
import { RB } from '../../constants/privileges';
|
|
12
|
+
import PrivilegeRoute from './privilege-route.component';
|
|
13
|
+
|
|
14
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
function sessionWith(privileges: string[], authenticated = true) {
|
|
17
|
+
return {
|
|
18
|
+
authenticated,
|
|
19
|
+
user: {
|
|
20
|
+
uuid: 'u-1',
|
|
21
|
+
display: 'User',
|
|
22
|
+
privileges: privileges.map((name) => ({ uuid: name, name, display: name })),
|
|
23
|
+
roles: [],
|
|
24
|
+
},
|
|
25
|
+
} as any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderAt(route: string, ui: React.ReactElement): { text: () => string; unmount: () => void } {
|
|
29
|
+
const container = document.createElement('div');
|
|
30
|
+
document.body.appendChild(container);
|
|
31
|
+
const root = createRoot(container);
|
|
32
|
+
act(() => {
|
|
33
|
+
root.render(<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>);
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
text: () => container.textContent ?? '',
|
|
37
|
+
unmount: () => act(() => root.unmount()),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('PrivilegeRoute', () => {
|
|
42
|
+
it('renders children when a required privilege is held (any-of)', () => {
|
|
43
|
+
(useSession as unknown as jest.Mock).mockReturnValue(sessionWith([RB.PACKAGE_EXPORT]));
|
|
44
|
+
const view = renderAt(
|
|
45
|
+
'/import-export',
|
|
46
|
+
<PrivilegeRoute required={[RB.PACKAGE_IMPORT, RB.PACKAGE_EXPORT]}>
|
|
47
|
+
<div>secret content</div>
|
|
48
|
+
</PrivilegeRoute>,
|
|
49
|
+
);
|
|
50
|
+
expect(view.text()).toContain('secret content');
|
|
51
|
+
view.unmount();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the access denied page naming the privilege when unauthorised', () => {
|
|
55
|
+
(useSession as unknown as jest.Mock).mockReturnValue(sessionWith([RB.REPORT_VIEW]));
|
|
56
|
+
const view = renderAt(
|
|
57
|
+
'/run',
|
|
58
|
+
<PrivilegeRoute required={[RB.REPORT_RUN]}>
|
|
59
|
+
<div>secret content</div>
|
|
60
|
+
</PrivilegeRoute>,
|
|
61
|
+
);
|
|
62
|
+
expect(view.text()).not.toContain('secret content');
|
|
63
|
+
expect(view.text()).toContain('Access denied');
|
|
64
|
+
expect(view.text()).toContain('report.run');
|
|
65
|
+
view.unmount();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders nothing and does not crash for an unauthenticated visitor', () => {
|
|
69
|
+
(useSession as unknown as jest.Mock).mockReturnValue(sessionWith([], false));
|
|
70
|
+
const view = renderAt(
|
|
71
|
+
'/',
|
|
72
|
+
<PrivilegeRoute required={[RB.REPORT_VIEW]}>
|
|
73
|
+
<div>secret content</div>
|
|
74
|
+
</PrivilegeRoute>,
|
|
75
|
+
);
|
|
76
|
+
expect(view.text()).toBe('');
|
|
77
|
+
view.unmount();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useReportBuilderPrivileges } from '../../hooks/use-report-builder-privileges';
|
|
4
|
+
import { redirectToLogin } from '../../utils/api-error.utils';
|
|
5
|
+
import AccessDeniedPage from './access-denied-page.component';
|
|
6
|
+
|
|
7
|
+
interface PrivilegeRouteProps {
|
|
8
|
+
/** Privileges granting access; any-of semantics, matching the backend's @Authorized. */
|
|
9
|
+
required: string[];
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Route guard: renders children only when the signed-in user holds at least one
|
|
15
|
+
* of the required privileges; otherwise renders the friendly access denied page.
|
|
16
|
+
* Unauthenticated visitors are sent to the login page instead (401 vs 403).
|
|
17
|
+
* This is UX only — the backend remains the security boundary.
|
|
18
|
+
*/
|
|
19
|
+
const PrivilegeRoute: React.FC<PrivilegeRouteProps> = ({ required, children }) => {
|
|
20
|
+
const { has, isAuthenticated } = useReportBuilderPrivileges();
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!isAuthenticated) {
|
|
24
|
+
redirectToLogin();
|
|
25
|
+
}
|
|
26
|
+
}, [isAuthenticated]);
|
|
27
|
+
|
|
28
|
+
if (!isAuthenticated) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!has(...required)) {
|
|
33
|
+
return <AccessDeniedPage requiredPrivileges={required} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return <>{children}</>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default PrivilegeRoute;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { RB, RB_PREFIX, ROUTE_PRIVILEGES, rbDisplay } from './privileges';
|
|
2
|
+
|
|
3
|
+
describe('privilege catalog', () => {
|
|
4
|
+
it('declares exactly the 46 backend privileges', () => {
|
|
5
|
+
expect(Object.keys(RB)).toHaveLength(46);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('builds every privilege from the canonical prefix', () => {
|
|
9
|
+
for (const value of Object.values(RB)) {
|
|
10
|
+
expect(value.startsWith(RB_PREFIX)).toBe(true);
|
|
11
|
+
// Backend suffixes are lowercase dotted domains/actions
|
|
12
|
+
expect(value).toMatch(/^Task: reportbuilder\.[a-z.]+$/);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps report.view matching the backend contract verbatim', () => {
|
|
17
|
+
expect(RB.REPORT_VIEW).toBe('Task: reportbuilder.report.view');
|
|
18
|
+
expect(RB.REPORT_EDIT).toBe('Task: reportbuilder.report.edit');
|
|
19
|
+
expect(RB.SQL_EXECUTE).toBe('Task: reportbuilder.sql.execute');
|
|
20
|
+
expect(RB.PACKAGE_IMPORT).toBe('Task: reportbuilder.package.import');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('strips the prefix for display', () => {
|
|
24
|
+
expect(rbDisplay(RB.REPORT_EDIT)).toBe('report.edit');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('route privilege map', () => {
|
|
29
|
+
it('always requires at least one privilege per guarded route', () => {
|
|
30
|
+
for (const [route, required] of Object.entries(ROUTE_PRIVILEGES)) {
|
|
31
|
+
expect(required.length).toBeGreaterThan(0);
|
|
32
|
+
for (const privilege of required) {
|
|
33
|
+
expect(Object.values(RB)).toContain(privilege);
|
|
34
|
+
}
|
|
35
|
+
expect(route).toMatch(/^\//);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('covers the landing, run, and admin landing routes', () => {
|
|
40
|
+
expect(ROUTE_PRIVILEGES['/']).toEqual([RB.REPORT_VIEW]);
|
|
41
|
+
expect(ROUTE_PRIVILEGES['/run']).toEqual([RB.REPORT_RUN]);
|
|
42
|
+
expect(ROUTE_PRIVILEGES['/admin']).toEqual([RB.REPORT_VIEW]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('gives import-export OR semantics (either package privilege)', () => {
|
|
46
|
+
expect(ROUTE_PRIVILEGES['/import-export']).toEqual([RB.PACKAGE_IMPORT, RB.PACKAGE_EXPORT]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does not guard redirect-only routes', () => {
|
|
50
|
+
expect(ROUTE_PRIVILEGES['/themes']).toBeUndefined();
|
|
51
|
+
expect(ROUTE_PRIVILEGES['*']).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privilege catalog for the report builder.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the backend catalog — api/.../security/ReportBuilderPrivileges.java and
|
|
5
|
+
* omod/src/main/resources/configuration/privileges/reportbuilder-privileges.csv
|
|
6
|
+
* (see openmrs-module-report-builder README.md -> Security: Privileges & Roles).
|
|
7
|
+
* Keep in sync — if the backend catalog changes, this file changes.
|
|
8
|
+
*
|
|
9
|
+
* This is the ONLY source file allowed to contain `Task: reportbuilder.` literals.
|
|
10
|
+
* The one exception is routes.json, whose `privileges` key gates the System
|
|
11
|
+
* Administration card link declaratively (framework-enforced) — keep that string
|
|
12
|
+
* in sync with RB.REPORT_VIEW.
|
|
13
|
+
*/
|
|
14
|
+
export const RB_PREFIX = 'Task: reportbuilder.';
|
|
15
|
+
|
|
16
|
+
const rb = (suffix: string) => `${RB_PREFIX}${suffix}`;
|
|
17
|
+
|
|
18
|
+
export const RB = {
|
|
19
|
+
// Report definitions
|
|
20
|
+
REPORT_VIEW: rb('report.view'),
|
|
21
|
+
REPORT_ADD: rb('report.add'),
|
|
22
|
+
REPORT_EDIT: rb('report.edit'),
|
|
23
|
+
REPORT_PURGE: rb('report.purge'),
|
|
24
|
+
REPORT_COMPILE: rb('report.compile'),
|
|
25
|
+
REPORT_RUN: rb('report.run'),
|
|
26
|
+
// Indicators
|
|
27
|
+
INDICATOR_VIEW: rb('indicator.view'),
|
|
28
|
+
INDICATOR_ADD: rb('indicator.add'),
|
|
29
|
+
INDICATOR_EDIT: rb('indicator.edit'),
|
|
30
|
+
INDICATOR_PURGE: rb('indicator.purge'),
|
|
31
|
+
// Report sections
|
|
32
|
+
SECTION_VIEW: rb('section.view'),
|
|
33
|
+
SECTION_ADD: rb('section.add'),
|
|
34
|
+
SECTION_EDIT: rb('section.edit'),
|
|
35
|
+
SECTION_PURGE: rb('section.purge'),
|
|
36
|
+
// Data themes
|
|
37
|
+
THEME_VIEW: rb('theme.view'),
|
|
38
|
+
THEME_ADD: rb('theme.add'),
|
|
39
|
+
THEME_EDIT: rb('theme.edit'),
|
|
40
|
+
THEME_PURGE: rb('theme.purge'),
|
|
41
|
+
// Dashboards
|
|
42
|
+
DASHBOARD_VIEW: rb('dashboard.view'),
|
|
43
|
+
DASHBOARD_ADD: rb('dashboard.add'),
|
|
44
|
+
DASHBOARD_EDIT: rb('dashboard.edit'),
|
|
45
|
+
DASHBOARD_PURGE: rb('dashboard.purge'),
|
|
46
|
+
// Report categories
|
|
47
|
+
CATEGORY_VIEW: rb('category.view'),
|
|
48
|
+
CATEGORY_ADD: rb('category.add'),
|
|
49
|
+
CATEGORY_EDIT: rb('category.edit'),
|
|
50
|
+
CATEGORY_PURGE: rb('category.purge'),
|
|
51
|
+
// Age categories & age groups
|
|
52
|
+
AGEGROUP_VIEW: rb('agegroup.view'),
|
|
53
|
+
AGEGROUP_ADD: rb('agegroup.add'),
|
|
54
|
+
AGEGROUP_EDIT: rb('agegroup.edit'),
|
|
55
|
+
AGEGROUP_PURGE: rb('agegroup.purge'),
|
|
56
|
+
// Report library
|
|
57
|
+
LIBRARY_VIEW: rb('library.view'),
|
|
58
|
+
LIBRARY_ADD: rb('library.add'),
|
|
59
|
+
LIBRARY_EDIT: rb('library.edit'),
|
|
60
|
+
LIBRARY_PURGE: rb('library.purge'),
|
|
61
|
+
// ETL sources
|
|
62
|
+
ETLSOURCE_VIEW: rb('etlsource.view'),
|
|
63
|
+
ETLSOURCE_ADD: rb('etlsource.add'),
|
|
64
|
+
ETLSOURCE_EDIT: rb('etlsource.edit'),
|
|
65
|
+
ETLSOURCE_PURGE: rb('etlsource.purge'),
|
|
66
|
+
// ETL health monitors
|
|
67
|
+
ETLMONITOR_VIEW: rb('etlmonitor.view'),
|
|
68
|
+
ETLMONITOR_ADD: rb('etlmonitor.add'),
|
|
69
|
+
ETLMONITOR_EDIT: rb('etlmonitor.edit'),
|
|
70
|
+
ETLMONITOR_PURGE: rb('etlmonitor.purge'),
|
|
71
|
+
// Cross-cutting
|
|
72
|
+
SCHEMA_VIEW: rb('schema.view'),
|
|
73
|
+
SQL_EXECUTE: rb('sql.execute'),
|
|
74
|
+
PACKAGE_IMPORT: rb('package.import'),
|
|
75
|
+
PACKAGE_EXPORT: rb('package.export'),
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
export type RbPrivilege = (typeof RB)[keyof typeof RB];
|
|
79
|
+
|
|
80
|
+
/** 'Task: reportbuilder.report.edit' -> 'report.edit' (for display only). */
|
|
81
|
+
export const rbDisplay = (privilege: string) => privilege.replace(RB_PREFIX, '');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Route -> required privileges (any-of semantics: holding at least one grants access).
|
|
85
|
+
* Mirrors the backend FRONTEND_SECURITY_INSTRUCTIONS.md route table, extended with the
|
|
86
|
+
* routes it omits (admin/themes, linelist family, dashboards/:code, etl-dashboard,
|
|
87
|
+
* import-export). `/themes` (redirect) and `*` (fallback) are intentionally absent.
|
|
88
|
+
*/
|
|
89
|
+
export const ROUTE_PRIVILEGES: Record<string, string[]> = {
|
|
90
|
+
'/': [RB.REPORT_VIEW],
|
|
91
|
+
'/reports': [RB.REPORT_VIEW],
|
|
92
|
+
'/new': [RB.REPORT_ADD],
|
|
93
|
+
'/edit/:reportId': [RB.REPORT_EDIT],
|
|
94
|
+
'/run': [RB.REPORT_RUN],
|
|
95
|
+
'/run-reports': [RB.REPORT_RUN],
|
|
96
|
+
'/indicators': [RB.INDICATOR_VIEW],
|
|
97
|
+
'/sections': [RB.SECTION_VIEW],
|
|
98
|
+
'/dashboards': [RB.DASHBOARD_VIEW],
|
|
99
|
+
'/dashboards/:code': [RB.DASHBOARD_VIEW],
|
|
100
|
+
'/etl-dashboard': [RB.DASHBOARD_VIEW],
|
|
101
|
+
'/linelist': [RB.REPORT_VIEW],
|
|
102
|
+
'/linelist/new': [RB.REPORT_ADD],
|
|
103
|
+
'/linelist/edit/:reportId': [RB.REPORT_EDIT],
|
|
104
|
+
'/linelist/run/:reportId': [RB.REPORT_RUN],
|
|
105
|
+
'/admin': [RB.REPORT_VIEW],
|
|
106
|
+
'/admin/report-categories': [RB.CATEGORY_VIEW],
|
|
107
|
+
'/admin/age-categories': [RB.AGEGROUP_VIEW],
|
|
108
|
+
'/admin/age-groups': [RB.AGEGROUP_VIEW],
|
|
109
|
+
'/admin/report-library': [RB.LIBRARY_VIEW],
|
|
110
|
+
'/admin/themes': [RB.THEME_VIEW],
|
|
111
|
+
'/admin/etl-sources': [RB.ETLSOURCE_VIEW],
|
|
112
|
+
'/admin/etl-tasks': [RB.PACKAGE_IMPORT],
|
|
113
|
+
'/admin/etl-browser': [RB.ETLSOURCE_VIEW],
|
|
114
|
+
'/admin/etl-monitors': [RB.ETLMONITOR_VIEW],
|
|
115
|
+
'/admin/etl-monitors/builder': [RB.ETLMONITOR_VIEW],
|
|
116
|
+
'/admin/dashboards': [RB.DASHBOARD_VIEW],
|
|
117
|
+
'/import-export': [RB.PACKAGE_IMPORT, RB.PACKAGE_EXPORT],
|
|
118
|
+
};
|