@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,151 @@
1
+ import { RB } from '../constants/privileges';
2
+ import { guardRejection, normalizeApiError, PrivilegeError } from './api-error.utils';
3
+
4
+ /** Axios-shaped error factory. */
5
+ function axiosError(status: number, data: unknown, message = 'Request failed with status code ' + status) {
6
+ return {
7
+ message,
8
+ response: { status, data },
9
+ };
10
+ }
11
+
12
+ const stubLocation = (partial: Record<string, unknown> = {}) => {
13
+ // jsdom's location is deletable in the test environment; fall back silently if not.
14
+ const w = window as any;
15
+ try {
16
+ delete w.location;
17
+ w.location = {
18
+ href: '',
19
+ pathname: '/openmrs/spa/report-builder/reports',
20
+ search: '',
21
+ ...partial,
22
+ };
23
+ // getOpenmrsSpaBase is a window global set by the SPA shell.
24
+ w.getOpenmrsSpaBase = () => '/openmrs/spa/';
25
+ w.spaBase = '/openmrs/spa';
26
+ } catch {
27
+ /* navigation stays a jsdom no-op log */
28
+ }
29
+ };
30
+
31
+ describe('normalizeApiError', () => {
32
+ it('passes AbortError through untouched', () => {
33
+ const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' });
34
+ expect(normalizeApiError(abort)).toBe(abort);
35
+ });
36
+
37
+ it('is idempotent for PrivilegeError', () => {
38
+ const already = new PrivilegeError({ status: 403, message: 'x' });
39
+ expect(normalizeApiError(already)).toBe(already);
40
+ });
41
+
42
+ describe('403 with a reportbuilder privilege in the message', () => {
43
+ it('builds a friendly message and parses the privilege', () => {
44
+ const error = normalizeApiError(
45
+ axiosError(403, {
46
+ error: { message: `Privilege required: ${RB.REPORT_EDIT}` },
47
+ }),
48
+ ) as PrivilegeError;
49
+
50
+ expect(error).toBeInstanceOf(PrivilegeError);
51
+ expect(error.status).toBe(403);
52
+ expect(error.requiredPrivileges).toEqual([RB.REPORT_EDIT]);
53
+ expect(error.message).toContain('permission');
54
+ expect(error.message).toContain('report.edit');
55
+ expect(error.message).not.toContain('Task:');
56
+ });
57
+
58
+ it('deduplicates repeated privileges and preserves the axios response', () => {
59
+ const original = axiosError(403, {
60
+ message: `Privilege required: ${RB.REPORT_ADD} or ${RB.REPORT_ADD}`,
61
+ });
62
+ const error = normalizeApiError(original) as PrivilegeError;
63
+ expect(error.requiredPrivileges).toEqual([RB.REPORT_ADD]);
64
+ expect(error.response).toEqual(original.response);
65
+ });
66
+ });
67
+
68
+ describe('403 from non-reportbuilder endpoints', () => {
69
+ it('keeps the server wording verbatim (e.g. core task scheduler)', () => {
70
+ const error = normalizeApiError(
71
+ axiosError(403, { error: { message: 'Privilege required: Manage Scheduler' } }),
72
+ ) as PrivilegeError;
73
+
74
+ expect(error).toBeInstanceOf(PrivilegeError);
75
+ expect(error.status).toBe(403);
76
+ expect(error.requiredPrivileges).toBeUndefined();
77
+ expect(error.message).toBe('Privilege required: Manage Scheduler');
78
+ });
79
+
80
+ it('falls back to a generic message when nothing readable is present', () => {
81
+ const error = normalizeApiError({ message: '', response: { status: 403, data: null } }) as PrivilegeError;
82
+ expect(error).toBeInstanceOf(PrivilegeError);
83
+ expect(error.message).toContain('permission');
84
+ });
85
+
86
+ it('keeps the axios message when there is no body', () => {
87
+ const error = normalizeApiError(axiosError(403, null)) as PrivilegeError;
88
+ expect(error.message).toBe('Request failed with status code 403');
89
+ });
90
+ });
91
+
92
+ describe('401 (expired session)', () => {
93
+ it('marks the error as an auth error and redirects to the SPA login page', () => {
94
+ stubLocation();
95
+ const error = normalizeApiError(
96
+ axiosError(401, { error: { message: 'Session has expired' } }),
97
+ ) as PrivilegeError;
98
+
99
+ expect(error).toBeInstanceOf(PrivilegeError);
100
+ expect(error.isAuthError).toBe(true);
101
+ expect(error.status).toBe(401);
102
+ expect(error.message).toMatch(/session/i);
103
+ expect(window.location.href).toContain('/openmrs/spa/login');
104
+ expect(window.location.href).toContain('returnTo=');
105
+ });
106
+
107
+ it('does not redirect when already on the login page', () => {
108
+ stubLocation({ pathname: '/openmrs/spa/login', href: 'about-to-stay' });
109
+ normalizeApiError(axiosError(401, null));
110
+ expect(window.location.href).toBe('about-to-stay');
111
+ });
112
+ });
113
+
114
+ it('maps other statuses to a generic error carrying the server message', () => {
115
+ const error = normalizeApiError(
116
+ axiosError(500, { error: { message: 'Boom' } }),
117
+ ) as Error & { status?: number };
118
+ expect(error).not.toBeInstanceOf(PrivilegeError);
119
+ expect(error.message).toBe('Boom');
120
+ expect(error.status).toBe(500);
121
+ });
122
+ });
123
+
124
+ describe('guardRejection', () => {
125
+ it('returns the resolved value', async () => {
126
+ await expect(guardRejection(Promise.resolve({ data: 42 }))).resolves.toEqual({ data: 42 });
127
+ });
128
+
129
+ it('normalizes rejections', async () => {
130
+ await expect(
131
+ guardRejection(Promise.reject(axiosError(403, { error: { message: `Privilege required: ${RB.REPORT_RUN}` } }))),
132
+ ).rejects.toBeInstanceOf(PrivilegeError);
133
+ });
134
+ });
135
+
136
+ describe('redirectToLogin loop guard', () => {
137
+ it('navigates at most once across repeated calls (module-level latch)', () => {
138
+ stubLocation();
139
+ jest.isolateModules(() => {
140
+ // eslint-disable-next-line @typescript-eslint/no-var-requires -- fresh module instance for the latch test
141
+ const fresh = require('./api-error.utils');
142
+ stubLocation();
143
+ fresh.redirectToLogin();
144
+ expect(window.location.href).toContain('/login');
145
+ // The latch makes further calls no-ops even with a fresh path.
146
+ stubLocation({ pathname: '/openmrs/spa/report-builder/admin', href: 'unchanged' });
147
+ fresh.redirectToLogin();
148
+ expect(window.location.href).toBe('unchanged');
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,131 @@
1
+ import { RB_PREFIX } from '../constants/privileges';
2
+
3
+ const RB_PRIVILEGE_REGEX = new RegExp(`${RB_PREFIX}[a-z.]+`, 'g');
4
+
5
+ /**
6
+ * Thrown by the omrs* helpers (and guarded direct openmrsFetch calls) when the
7
+ * backend rejects a request for lack of privileges (403) or an expired session
8
+ * (401). The message is user-facing — pages render `e?.message` directly.
9
+ */
10
+ export class PrivilegeError extends Error {
11
+ readonly status?: number;
12
+ /** Full privilege names parsed out of the server message, e.g. `Task: reportbuilder.report.edit`. */
13
+ readonly requiredPrivileges?: string[];
14
+ readonly serverMessage?: string;
15
+ readonly isAuthError: boolean;
16
+ /** Original axios response, kept so downstream status checks keep working. */
17
+ readonly response?: unknown;
18
+
19
+ constructor(init: {
20
+ status?: number;
21
+ message: string;
22
+ requiredPrivileges?: string[];
23
+ serverMessage?: string;
24
+ isAuthError?: boolean;
25
+ response?: unknown;
26
+ }) {
27
+ super(init.message);
28
+ this.name = 'PrivilegeError';
29
+ this.status = init.status;
30
+ this.requiredPrivileges = init.requiredPrivileges;
31
+ this.serverMessage = init.serverMessage;
32
+ this.isAuthError = init.isAuthError ?? false;
33
+ this.response = init.response;
34
+ }
35
+ }
36
+
37
+ function extractServerMessage(error: any): string {
38
+ const data = error?.response?.data;
39
+ if (typeof data === 'string') {
40
+ return data;
41
+ }
42
+ return data?.error?.message ?? data?.message ?? error?.message ?? '';
43
+ }
44
+
45
+ /**
46
+ * Maps raw openmrsFetch (axios) rejections to friendly errors:
47
+ * - 401: session expired -> friendly message + one redirect to the login page.
48
+ * - 403 carrying `Task: reportbuilder.*` -> PrivilegeError naming the privilege.
49
+ * - other 403s (e.g. OpenMRS core endpoints): keep the server's own wording.
50
+ * - aborts/cancellations pass through untouched so page AbortController filters keep working.
51
+ */
52
+ export function normalizeApiError(error: any, fallbackMessage = 'Request failed'): Error {
53
+ if (error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
54
+ return error;
55
+ }
56
+ if (error instanceof PrivilegeError) {
57
+ return error;
58
+ }
59
+
60
+ const status: number | undefined = error?.response?.status;
61
+ const serverMessage = extractServerMessage(error);
62
+
63
+ if (status === 401) {
64
+ redirectToLogin();
65
+ return new PrivilegeError({
66
+ status: 401,
67
+ isAuthError: true,
68
+ serverMessage,
69
+ response: error?.response,
70
+ message: 'Your session has expired. Please sign in again.',
71
+ });
72
+ }
73
+
74
+ if (status === 403) {
75
+ const matches = serverMessage.match(RB_PRIVILEGE_REGEX) ?? [];
76
+ if (matches.length > 0) {
77
+ const requiredPrivileges = [...new Set(matches)];
78
+ const names = requiredPrivileges.map((privilege) => privilege.replace(RB_PREFIX, '')).join(', ');
79
+ return new PrivilegeError({
80
+ status: 403,
81
+ requiredPrivileges,
82
+ serverMessage,
83
+ response: error?.response,
84
+ message: `You do not have permission to perform this action. Required privilege: ${names}.`,
85
+ });
86
+ }
87
+ return new PrivilegeError({
88
+ status: 403,
89
+ serverMessage,
90
+ response: error?.response,
91
+ message: serverMessage || 'You do not have permission to perform this action.',
92
+ });
93
+ }
94
+
95
+ const generic = new Error(serverMessage || fallbackMessage) as Error & {
96
+ status?: number;
97
+ response?: unknown;
98
+ };
99
+ generic.status = status;
100
+ generic.response = error?.response;
101
+ return generic;
102
+ }
103
+
104
+ /** Wraps a promise from a direct openmrsFetch call so rejections are normalized. */
105
+ export async function guardRejection<T>(promise: Promise<T>): Promise<T> {
106
+ try {
107
+ return await promise;
108
+ } catch (error) {
109
+ throw normalizeApiError(error);
110
+ }
111
+ }
112
+
113
+ let redirecting = false;
114
+
115
+ /** Sends the browser to the SPA login page. Loop-guarded: skipped when already
116
+ * on /login and latched so parallel 401s cause a single navigation. */
117
+ export function redirectToLogin(): void {
118
+ if (typeof window === 'undefined' || redirecting) {
119
+ return;
120
+ }
121
+ const { pathname, search } = window.location;
122
+ if (pathname.includes('/login')) {
123
+ return;
124
+ }
125
+ redirecting = true;
126
+ const base =
127
+ typeof window.getOpenmrsSpaBase === 'function'
128
+ ? window.getOpenmrsSpaBase()
129
+ : `${window.spaBase}/`;
130
+ window.location.href = `${base}login?returnTo=${encodeURIComponent(`${pathname}${search}`)}`;
131
+ }