@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.
Files changed (52) hide show
  1. package/__mocks__/react-i18next.js +12 -0
  2. package/dist/729.js +1 -1
  3. package/dist/760.js +1 -0
  4. package/dist/760.js.map +1 -0
  5. package/dist/main.js +1 -1
  6. package/dist/openmrs-esm-report-builder.js.buildmanifest.json +26 -26
  7. package/dist/routes.json +1 -1
  8. package/package.json +1 -1
  9. package/routes.json +3 -2
  10. package/src/components/admin/admin-page.component.tsx +53 -24
  11. package/src/components/admin/etl-browser/etl-browser.component.scss +10 -3
  12. package/src/components/admin/etl-browser/preview-grid.component.tsx +1 -1
  13. package/src/components/admin/etl-sources-page.component.tsx +30 -19
  14. package/src/components/admin/report-categories-page.component.tsx +32 -21
  15. package/src/components/admin/report-library-page.component.tsx +30 -19
  16. package/src/components/app-shell/report-builder-shell.component.tsx +194 -162
  17. package/src/components/app-shell/report-builder-shell.scss +4 -1
  18. package/src/components/dashboards/dashboards-page.component.tsx +13 -6
  19. package/src/components/dashboards/dashboards-table.component.tsx +24 -17
  20. package/src/components/data-themes/data-themes-page.component.tsx +11 -4
  21. package/src/components/data-themes/data-themes-table.component.tsx +10 -3
  22. package/src/components/data-visualizer/data-visualizer.resource.ts +49 -37
  23. package/src/components/etl-monitor/builder/etl-monitor-builder-page.component.tsx +6 -1
  24. package/src/components/etl-monitor/etl-monitors-page.component.tsx +35 -24
  25. package/src/components/import-export/import-export-page.component.tsx +38 -22
  26. package/src/components/indicators/indicators-page.component.tsx +41 -6
  27. package/src/components/indicators/indicators-table.component.tsx +24 -4
  28. package/src/components/landing/report-builder-landing-page.component.tsx +34 -1
  29. package/src/components/linelist/definition/linelist-definition-editor.component.tsx +14 -2
  30. package/src/components/linelist/linelist-dashboard.page.component.tsx +20 -13
  31. package/src/components/report/report-dashboard.page.component.tsx +14 -8
  32. package/src/components/report/report-editor.page.component.tsx +6 -1
  33. package/src/components/report-sections/report-sections-page.component.tsx +17 -5
  34. package/src/components/security/access-denied-page.component.tsx +69 -0
  35. package/src/components/security/access-denied-page.scss +39 -0
  36. package/src/components/security/privilege-route.component.test.tsx +79 -0
  37. package/src/components/security/privilege-route.component.tsx +39 -0
  38. package/src/constants/privileges.test.ts +53 -0
  39. package/src/constants/privileges.ts +118 -0
  40. package/src/hooks/use-report-builder-privileges.test.tsx +98 -0
  41. package/src/hooks/use-report-builder-privileges.ts +58 -0
  42. package/src/resources/etl-monitor/etl-monitor-test.api.ts +5 -2
  43. package/src/resources/etl-task/etl-task.api.ts +21 -13
  44. package/src/resources/legacy-reports/legacy-reports.api.ts +10 -22
  45. package/src/resources/openmrs-api.ts +30 -14
  46. package/src/resources/report-section/section-preview.api.ts +10 -6
  47. package/src/routes/report-builder.routes.tsx +93 -28
  48. package/src/routes.json +3 -2
  49. package/src/utils/api-error.utils.test.ts +151 -0
  50. package/src/utils/api-error.utils.ts +131 -0
  51. package/dist/359.js +0 -1
  52. package/dist/359.js.map +0 -1
@@ -0,0 +1,98 @@
1
+ import React from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { act } from 'react-dom/test-utils';
4
+
5
+ import { useSession } from '@openmrs/esm-framework';
6
+ import { useReportBuilderPrivileges } from './use-report-builder-privileges';
7
+ import { RB } from '../constants/privileges';
8
+
9
+ jest.mock('@openmrs/esm-framework', () => ({
10
+ useSession: jest.fn(),
11
+ }));
12
+
13
+ const mockUseSession = useSession as unknown as jest.Mock;
14
+
15
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ const priv = (name: string) => ({ uuid: name, name, display: name });
18
+
19
+ function renderHook<T>(hook: () => T): { current: T | undefined; rerender: () => void; unmount: () => void } {
20
+ const container = document.createElement('div');
21
+ const root = createRoot(container);
22
+ let current: T | undefined;
23
+ const Probe = () => {
24
+ current = hook();
25
+ return null;
26
+ };
27
+ const render = () => act(() => {
28
+ root.render(React.createElement(Probe));
29
+ });
30
+ render();
31
+ return {
32
+ get current() {
33
+ return current;
34
+ },
35
+ rerender: render,
36
+ unmount: () => act(() => root.unmount()),
37
+ };
38
+ }
39
+
40
+ function sessionWith(privileges: string[], roles: string[] = [], authenticated = true) {
41
+ return {
42
+ authenticated,
43
+ user: {
44
+ uuid: 'u-1',
45
+ display: 'User',
46
+ privileges: privileges.map(priv),
47
+ roles: roles.map((r) => ({ uuid: r, display: r })),
48
+ },
49
+ } as any;
50
+ }
51
+
52
+ describe('useReportBuilderPrivileges', () => {
53
+ it('matches held privileges by name', () => {
54
+ mockUseSession.mockReturnValue(sessionWith([RB.REPORT_VIEW]));
55
+ const { current, unmount } = renderHook(() => useReportBuilderPrivileges());
56
+ expect(current!.has(RB.REPORT_VIEW)).toBe(true);
57
+ expect(current!.has(RB.REPORT_EDIT)).toBe(false);
58
+ expect(current!.isAuthenticated).toBe(true);
59
+ unmount();
60
+ });
61
+
62
+ it('uses OR semantics like the backend @Authorized({a, b})', () => {
63
+ mockUseSession.mockReturnValue(sessionWith([RB.REPORT_ADD]));
64
+ const { current, unmount } = renderHook(() => useReportBuilderPrivileges());
65
+ expect(current!.has(RB.REPORT_ADD, RB.REPORT_EDIT)).toBe(true);
66
+ expect(current!.hasAll(RB.REPORT_ADD, RB.REPORT_EDIT)).toBe(false);
67
+ unmount();
68
+ });
69
+
70
+ it('treats the System Developer role as superuser', () => {
71
+ mockUseSession.mockReturnValue(sessionWith([], ['System Developer']));
72
+ const { current, unmount } = renderHook(() => useReportBuilderPrivileges());
73
+ expect(current!.isSuperUser).toBe(true);
74
+ expect(current!.has(RB.ETLMONITOR_PURGE)).toBe(true);
75
+ expect(current!.hasAll(RB.PACKAGE_IMPORT, RB.PACKAGE_EXPORT)).toBe(true);
76
+ unmount();
77
+ });
78
+
79
+ it('exposes convenience flags', () => {
80
+ mockUseSession.mockReturnValue(
81
+ sessionWith([RB.REPORT_VIEW, RB.REPORT_RUN, RB.PACKAGE_EXPORT]),
82
+ );
83
+ const { current, unmount } = renderHook(() => useReportBuilderPrivileges());
84
+ expect(current!.canAuthor).toBe(false);
85
+ expect(current!.canRun).toBe(true);
86
+ expect(current!.canPublish).toBe(true);
87
+ expect(current!.canManageEtl).toBe(false);
88
+ unmount();
89
+ });
90
+
91
+ it('is false on everything for an unauthenticated session', () => {
92
+ mockUseSession.mockReturnValue({ authenticated: false, user: undefined });
93
+ const { current, unmount } = renderHook(() => useReportBuilderPrivileges());
94
+ expect(current!.isAuthenticated).toBe(false);
95
+ expect(current!.has(RB.REPORT_VIEW)).toBe(false);
96
+ unmount();
97
+ });
98
+ });
@@ -0,0 +1,58 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { useSession } from '@openmrs/esm-framework';
3
+ import { RB } from '../constants/privileges';
4
+
5
+ const SUPERUSER_ROLE = 'System Developer';
6
+
7
+ /**
8
+ * Privileges of the signed-in user, as resolved by the backend session endpoint
9
+ * (inherited roles already expanded). Backed by the framework session store —
10
+ * the same fetch the SPA shell and headers already use — so there is no extra
11
+ * network call and values refresh automatically on re-login.
12
+ *
13
+ * `has` uses OR semantics: has(RB.REPORT_ADD, RB.REPORT_EDIT) is true when the
14
+ * user holds either, mirroring the backend's @Authorized({a, b}) behaviour.
15
+ */
16
+ export function useReportBuilderPrivileges() {
17
+ const session = useSession();
18
+ const user = session?.user;
19
+
20
+ const held = useMemo(() => {
21
+ const names = new Set<string>();
22
+ for (const privilege of user?.privileges ?? []) {
23
+ names.add(privilege.name);
24
+ names.add(privilege.display);
25
+ }
26
+ return names;
27
+ }, [user]);
28
+
29
+ const isSuperUser = useMemo(
30
+ () => !!user?.roles?.some((role) => role.display === SUPERUSER_ROLE),
31
+ [user],
32
+ );
33
+
34
+ const has = useCallback(
35
+ (...needed: string[]) =>
36
+ needed.length === 0 || isSuperUser || needed.some((privilege) => held.has(privilege)),
37
+ [held, isSuperUser],
38
+ );
39
+
40
+ const hasAll = useCallback(
41
+ (...needed: string[]) =>
42
+ needed.length === 0 || isSuperUser || needed.every((privilege) => held.has(privilege)),
43
+ [held, isSuperUser],
44
+ );
45
+
46
+ return {
47
+ /** has(RB.X, RB.Y) — true when the user holds ANY of the privileges. */
48
+ has,
49
+ /** hasAll(RB.X, RB.Y) — true only when the user holds ALL of the privileges. */
50
+ hasAll,
51
+ isSuperUser,
52
+ isAuthenticated: !!session?.authenticated,
53
+ canAuthor: has(RB.REPORT_ADD, RB.REPORT_EDIT),
54
+ canRun: has(RB.REPORT_RUN),
55
+ canPublish: has(RB.PACKAGE_IMPORT, RB.PACKAGE_EXPORT),
56
+ canManageEtl: has(RB.ETLSOURCE_ADD, RB.ETLSOURCE_EDIT, RB.ETLMONITOR_ADD, RB.ETLMONITOR_EDIT),
57
+ };
58
+ }
@@ -221,9 +221,12 @@ export async function testEndpointConnection(
221
221
 
222
222
  if (error?.response?.status === 403) {
223
223
  return {
224
- success: false,
225
- error: 'Permission denied. You may not have the required privileges to test endpoints.',
224
+ // PrivilegeError (from the omrs* helpers) already carries a user-friendly message.
225
+ error:
226
+ error?.message ??
227
+ 'Permission denied. You may not have the required privileges to test endpoints.',
226
228
  errorCode: 'FORBIDDEN',
229
+ success: false,
227
230
  durationMs,
228
231
  testedAt: new Date().toISOString(),
229
232
  };
@@ -6,14 +6,20 @@
6
6
  import { openmrsFetch } from '@openmrs/esm-framework';
7
7
  import type { OpenMRSTaskDefinition, ETLTaskExecutionResponse } from './etl-task.types';
8
8
 
9
+ // These are OpenMRS core endpoints (scheduler), not reportbuilder ones - a 403 here
10
+ // carries a core privilege name, which the error normalizer passes through verbatim.
11
+ import { guardRejection } from '../../utils/api-error.utils';
12
+
9
13
  /**
10
14
  * Fetch all available OpenMRS task definitions (with scheduler metadata)
11
15
  */
12
16
  export async function fetchAllTaskDefinitions(signal?: AbortSignal): Promise<OpenMRSTaskDefinition[]> {
13
17
  try {
14
- const response = await openmrsFetch(
15
- '/ws/rest/v1/taskdefinition?v=custom:(uuid,name,description,taskClass,started,lastExecutionTime,repeatInterval)',
16
- { signal },
18
+ const response = await guardRejection(
19
+ openmrsFetch(
20
+ '/ws/rest/v1/taskdefinition?v=custom:(uuid,name,description,taskClass,started,lastExecutionTime,repeatInterval)',
21
+ { signal },
22
+ ),
17
23
  );
18
24
  const data = await response.json();
19
25
  return data.results || [];
@@ -62,16 +68,18 @@ export async function fetchConfiguredTasks(
62
68
  */
63
69
  export async function executeETLTask(taskName: string): Promise<ETLTaskExecutionResponse> {
64
70
  try {
65
- const response = await openmrsFetch('/ws/rest/v1/taskaction', {
66
- method: 'POST',
67
- headers: {
68
- 'Content-Type': 'application/json',
69
- },
70
- body: {
71
- action: 'runtask',
72
- tasks: [taskName],
73
- },
74
- });
71
+ const response = await guardRejection(
72
+ openmrsFetch('/ws/rest/v1/taskaction', {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ },
77
+ body: {
78
+ action: 'runtask',
79
+ tasks: [taskName],
80
+ },
81
+ }),
82
+ );
75
83
 
76
84
  if (response.status !== 201) {
77
85
  throw new Error(`Task execution failed with status ${response.status}`);
@@ -1,23 +1,9 @@
1
1
  import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
2
 
3
- const RESOURCE = '/reportbuilder/legacy';
4
-
5
- async function omrsGet<T>(path: string, signal?: AbortSignal): Promise<T> {
6
- const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
7
- const res = await openmrsFetch<T>(url, { signal });
8
- return res.data;
9
- }
3
+ import { guardRejection } from '../../utils/api-error.utils';
4
+ import { omrsGet, omrsPost } from '../openmrs-api';
10
5
 
11
- async function omrsPost<T>(path: string, body: any, signal?: AbortSignal): Promise<T> {
12
- const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
13
- const res = await openmrsFetch<T>(url, {
14
- method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
16
- body,
17
- signal,
18
- });
19
- return res.data;
20
- }
6
+ const RESOURCE = '/reportbuilder/legacy';
21
7
 
22
8
  export type LegacyReportDto = {
23
9
  uuid: string;
@@ -122,11 +108,13 @@ export async function uploadLegacyReport(
122
108
 
123
109
  const url = `${restBaseUrl}${RESOURCE}/upload`;
124
110
 
125
- const response = await openmrsFetch(url, {
126
- method: 'POST',
127
- body: formData,
128
- signal,
129
- });
111
+ const response = await guardRejection(
112
+ openmrsFetch(url, {
113
+ method: 'POST',
114
+ body: formData,
115
+ signal,
116
+ }),
117
+ );
130
118
 
131
119
  if (!response.ok) {
132
120
  throw new Error(`HTTP error! status: ${response.status}`);
@@ -1,24 +1,40 @@
1
1
  import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2
2
 
3
+ import { normalizeApiError } from '../utils/api-error.utils';
4
+
5
+ async function run<T>(request: () => Promise<T>): Promise<T> {
6
+ try {
7
+ return await request();
8
+ } catch (error) {
9
+ throw normalizeApiError(error);
10
+ }
11
+ }
12
+
3
13
  export async function omrsGet<T>(path: string, signal?: AbortSignal): Promise<T> {
4
- const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
5
- const res = await openmrsFetch<T>(url, { signal });
6
- return res.data;
14
+ return run(async () => {
15
+ const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
16
+ const res = await openmrsFetch<T>(url, { signal });
17
+ return res.data;
18
+ });
7
19
  }
8
20
 
9
21
  export async function omrsPost<T>(path: string, body: any, signal?: AbortSignal): Promise<T> {
10
- const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
11
- const res = await openmrsFetch<T>(url, {
12
- method: 'POST',
13
- headers: { 'Content-Type': 'application/json' },
14
- body,
15
- signal,
22
+ return run(async () => {
23
+ const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
24
+ const res = await openmrsFetch<T>(url, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body,
28
+ signal,
29
+ });
30
+ return res.data;
16
31
  });
17
- return res.data;
18
32
  }
19
33
 
20
34
  export async function omrsDelete<T>(path: string, signal?: AbortSignal): Promise<T> {
21
- const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
22
- const res = await openmrsFetch<T>(url, { method: 'DELETE', signal });
23
- return res.data;
24
- }
35
+ return run(async () => {
36
+ const url = `${restBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
37
+ const res = await openmrsFetch<T>(url, { method: 'DELETE', signal });
38
+ return res.data;
39
+ });
40
+ }
@@ -1,5 +1,7 @@
1
1
  import { openmrsFetch } from '@openmrs/esm-framework';
2
2
 
3
+ import { guardRejection } from '../../utils/api-error.utils';
4
+
3
5
  export type SectionPreviewRequest = {
4
6
  sectionUuid: string;
5
7
  indicatorUuid?: string;
@@ -10,12 +12,14 @@ export type SectionPreviewRequest = {
10
12
  };
11
13
 
12
14
  export async function previewSection(body: SectionPreviewRequest, signal?: AbortSignal) {
13
- const { data } = await openmrsFetch('/ws/rest/v1/reportbuilder/sectionpreview', {
14
- method: 'POST',
15
- headers: { 'Content-Type': 'application/json' },
16
- body,
17
- signal,
18
- });
15
+ const { data } = await guardRejection(
16
+ openmrsFetch('/ws/rest/v1/reportbuilder/sectionpreview', {
17
+ method: 'POST',
18
+ headers: { 'Content-Type': 'application/json' },
19
+ body,
20
+ signal,
21
+ }),
22
+ );
19
23
 
20
24
  return data;
21
25
  }
@@ -31,55 +31,120 @@ import LinelistRunReport from '../components/linelist/linelist-run-report.page.c
31
31
 
32
32
  import { ImportExportPage } from '../components/import-export';
33
33
 
34
+ import PrivilegeRoute from '../components/security/privilege-route.component';
35
+ import { ROUTE_PRIVILEGES } from '../constants/privileges';
36
+
37
+ /** Wraps a route element with its required privileges (see ROUTE_PRIVILEGES). */
38
+ const guard = (route: keyof typeof ROUTE_PRIVILEGES, element: React.ReactNode) => (
39
+ <PrivilegeRoute required={ROUTE_PRIVILEGES[route]}>{element}</PrivilegeRoute>
40
+ );
41
+
34
42
  const ReportBuilderRoutes: React.FC = () => {
35
43
  return (
36
44
  <Routes>
37
45
  <Route element={<ReportBuilderShell />}>
38
46
  {/* Home / landing */}
39
- <Route path="/" element={<ReportBuilderLandingPage />} />
47
+ <Route path="/" element={guard('/', <ReportBuilderLandingPage />)} />
40
48
 
41
49
  {/* Reports */}
42
- <Route path="/reports" element={<ReportDashboardPage />} />
50
+ <Route path="/reports" element={guard('/reports', <ReportDashboardPage />)} />
43
51
 
44
52
  {/* Create / edit report */}
45
- <Route path="/new" element={<ReportEditorPage />} />
46
- <Route path="/edit/:reportId" element={<ReportEditorPage />} />
53
+ <Route path="/new" element={guard('/new', <ReportEditorPage />)} />
54
+ <Route
55
+ path="/edit/:reportId"
56
+ element={guard('/edit/:reportId', <ReportEditorPage />)}
57
+ />
47
58
 
48
59
  {/* Indicators / sections */}
49
- <Route path="/indicators" element={<IndicatorsPage />} />
50
- <Route path="/sections" element={<SectionsPage />} />
60
+ <Route path="/indicators" element={guard('/indicators', <IndicatorsPage />)} />
61
+ <Route path="/sections" element={guard('/sections', <SectionsPage />)} />
51
62
 
52
63
  {/* Run */}
53
- <Route path="/run" element={<ReportVisualizerPage />} />
54
- <Route path="/run-reports" element={<ReportVisualizerPage />} />
64
+ <Route path="/run" element={guard('/run', <ReportVisualizerPage />)} />
65
+ <Route
66
+ path="/run-reports"
67
+ element={guard('/run-reports', <ReportVisualizerPage />)}
68
+ />
55
69
 
56
70
  {/* Admin */}
57
- <Route path="/admin" element={<AdminPage />} />
58
- <Route path="/admin/report-categories" element={<ReportCategoriesPage />} />
59
- <Route path="/admin/age-categories" element={<AgeCategoriesPage />} />
60
- <Route path="/admin/age-groups" element={<AgeGroupsPage />} />
61
- <Route path="/admin/report-library" element={<ReportLibraryPage />} />
62
- <Route path="/admin/etl-sources" element={<ETLSourcesPage />} />
63
- <Route path="/admin/etl-tasks" element={<ETLTasksPage />} />
64
- <Route path="/admin/etl-monitors" element={<ETLMonitorsPage />} />
65
- <Route path="/admin/etl-monitors/builder" element={<EtlMonitorBuilderPage />} />
66
- <Route path="/admin/etl-browser" element={<EtlBrowserPage />} />
67
- <Route path="/admin/dashboards" element={<DashboardsAdminPage />} />
68
- <Route path="/dashboards" element={<DashboardsPage />} />
69
- <Route path="/dashboards/:code" element={<DashboardRendererPage />} />
70
- <Route path="/etl-dashboard" element={<EtlDashboardPage />} />
71
+ <Route path="/admin" element={guard('/admin', <AdminPage />)} />
72
+ <Route
73
+ path="/admin/report-categories"
74
+ element={guard('/admin/report-categories', <ReportCategoriesPage />)}
75
+ />
76
+ <Route
77
+ path="/admin/age-categories"
78
+ element={guard('/admin/age-categories', <AgeCategoriesPage />)}
79
+ />
80
+ <Route
81
+ path="/admin/age-groups"
82
+ element={guard('/admin/age-groups', <AgeGroupsPage />)}
83
+ />
84
+ <Route
85
+ path="/admin/report-library"
86
+ element={guard('/admin/report-library', <ReportLibraryPage />)}
87
+ />
88
+ <Route
89
+ path="/admin/etl-sources"
90
+ element={guard('/admin/etl-sources', <ETLSourcesPage />)}
91
+ />
92
+ <Route
93
+ path="/admin/etl-tasks"
94
+ element={guard('/admin/etl-tasks', <ETLTasksPage />)}
95
+ />
96
+ <Route
97
+ path="/admin/etl-monitors"
98
+ element={guard('/admin/etl-monitors', <ETLMonitorsPage />)}
99
+ />
100
+ <Route
101
+ path="/admin/etl-monitors/builder"
102
+ element={guard('/admin/etl-monitors/builder', <EtlMonitorBuilderPage />)}
103
+ />
104
+ <Route
105
+ path="/admin/etl-browser"
106
+ element={guard('/admin/etl-browser', <EtlBrowserPage />)}
107
+ />
108
+ <Route
109
+ path="/admin/dashboards"
110
+ element={guard('/admin/dashboards', <DashboardsAdminPage />)}
111
+ />
112
+ <Route path="/dashboards" element={guard('/dashboards', <DashboardsPage />)} />
113
+ <Route
114
+ path="/dashboards/:code"
115
+ element={guard('/dashboards/:code', <DashboardRendererPage />)}
116
+ />
117
+ <Route
118
+ path="/etl-dashboard"
119
+ element={guard('/etl-dashboard', <EtlDashboardPage />)}
120
+ />
71
121
 
72
122
  {/* Data themes now accessed under Admin */}
73
- <Route path="/admin/themes" element={<DataThemesPage />} />
123
+ <Route
124
+ path="/admin/themes"
125
+ element={guard('/admin/themes', <DataThemesPage />)}
126
+ />
74
127
 
75
128
  {/* Linelist Reports */}
76
- <Route path="/linelist" element={<LinelistReportsPage />} />
77
- <Route path="/linelist/new" element={<LinelistBuilderWorkspace />} />
78
- <Route path="/linelist/edit/:reportId" element={<LinelistBuilderWorkspace />} />
79
- <Route path="/linelist/run/:reportId" element={<LinelistRunReport />} />
129
+ <Route path="/linelist" element={guard('/linelist', <LinelistReportsPage />)} />
130
+ <Route
131
+ path="/linelist/new"
132
+ element={guard('/linelist/new', <LinelistBuilderWorkspace />)}
133
+ />
134
+ <Route
135
+ path="/linelist/edit/:reportId"
136
+ element={guard('/linelist/edit/:reportId', <LinelistBuilderWorkspace />)}
137
+ />
138
+ <Route
139
+ path="/linelist/run/:reportId"
140
+ element={guard('/linelist/run/:reportId', <LinelistRunReport />)}
141
+ />
80
142
 
81
143
  {/* Import / Export */}
82
- <Route path="/import-export" element={<ImportExportPage />} />
144
+ <Route
145
+ path="/import-export"
146
+ element={guard('/import-export', <ImportExportPage />)}
147
+ />
83
148
 
84
149
  {/* Optional backward compatibility */}
85
150
  <Route path="/themes" element={<Navigate to="/admin/themes" replace />} />
package/src/routes.json CHANGED
@@ -15,7 +15,8 @@
15
15
  "slot": "system-admin-page-card-link-slot",
16
16
  "component": "reportBuilderAdminLink",
17
17
  "online": true,
18
- "offline": false
18
+ "offline": false,
19
+ "privileges": "Task: reportbuilder.report.view"
19
20
  },
20
21
  {
21
22
  "component": "confirmModal",
@@ -24,4 +25,4 @@
24
25
  "offline": true
25
26
  }
26
27
  ]
27
- }
28
+ }