@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
@@ -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
- <Button
497
- kind="ghost"
498
- size="md"
499
- renderIcon={Add}
500
- onClick={() => navigate('/linelist/new')}
501
- >
502
- Create linelist report
503
- </Button>
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
- <Button kind="primary" renderIcon={Add} onClick={() => navigate('/linelist/new')}>
595
- Create Linelist Report
596
- </Button>
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
- <Button
366
- kind="ghost"
367
- size="md"
368
- renderIcon={Add}
369
- onClick={() => navigate('/new')}
370
- >
371
- Create Report
372
- </Button>
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 = Boolean((savedReport?.uuid || reportId || form.name.trim()) && !saving && !compiling && !loading);
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
- <Button size="sm" kind="primary" renderIcon={Add} onClick={() => setOpenCreate(true)}>
197
- {t('createSection', 'Create Section')}
198
- </Button>
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
- <OverflowMenuItem itemText="Preview" onClick={() => openPreview(row.id)} />
242
- <OverflowMenuItem itemText="Edit" onClick={() => openEdit(row.id)} />
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
+ };