@controleonline/ui-common 1.2.101 → 1.2.103
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/.release/rc-manifest.json +7 -7
- package/docs/homepage-1056.md +1 -5
- package/package.json +1 -1
- package/src/api/index.js +40 -54
- package/src/react/components/AnimatedModal.js +6 -1
- package/src/react/components/BottomNavigationBar.js +6 -5
- package/src/react/components/BottomNavigationBar.soft.styles.js +5 -4
- package/src/react/components/ProductCatalogCacheService.js +53 -45
- package/src/react/components/RuntimeBottomNavigationBar.js +11 -4
- package/src/react/components/UserAvatar.js +7 -16
- package/src/react/hooks/useBottomNavigationPreference.js +49 -0
- package/src/react/pages/Devices/deviceTypes/DeviceGroupCard.js +6 -4
- package/src/react/pages/Devices/deviceTypes/shared.js +9 -4
- package/src/react/pages/Devices.js +184 -183
- package/src/react/pages/IntegrationConfigPage.js +59 -13
- package/src/react/pages/IntegrationConfigPage.utils.js +17 -0
- package/src/react/pages/integrationsCatalog.js +71 -0
- package/src/react/utils/fileUrl.js +92 -11
- package/src/react/utils/productCatalogCacheRevision.js +13 -0
- package/src/tests/react/components/UserAvatar.test.js +30 -0
- package/src/tests/react/utils/fileUrl.test.js +82 -6
- package/src/tests/react/utils/runtimeFooter.test.js +189 -189
- package/src/tests/react/utils/storedSessionToken.test.js +22 -0
- package/src/utils/integrationConfigs.js +23 -0
- package/src/utils/storedSessionToken.js +18 -0
- package/src/utils/trustedApiUrl.js +19 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {env as APP_ENV} from '@env';
|
|
2
|
+
import {filterApiHeaders} from '../../utils/trustedApiUrl';
|
|
2
3
|
import {buildAssetUrl} from '@controleonline/../../src/styles/branding';
|
|
3
4
|
import {
|
|
4
5
|
resolveAppDomain,
|
|
@@ -88,6 +89,60 @@ const resolveDownloadHost = ({company = null, appDomain = ''} = {}) => {
|
|
|
88
89
|
* Keep app-domain header only; do not path-inject environment hosts.
|
|
89
90
|
* app-community#432
|
|
90
91
|
*/
|
|
92
|
+
const resolveApiEntrypointHost = () => {
|
|
93
|
+
const entry = normalizeText(APP_ENV?.API_ENTRYPOINT);
|
|
94
|
+
if (!entry) {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(entry)
|
|
100
|
+
? entry
|
|
101
|
+
: `https://${entry}`;
|
|
102
|
+
return new URL(withProtocol).host.toLowerCase();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return entry
|
|
105
|
+
.replace(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//, '')
|
|
106
|
+
.replace(/\/.*$/, '')
|
|
107
|
+
.toLowerCase();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* API host must never be used as /{host}/files/{id}/download segment.
|
|
113
|
+
* Backend File.domain often stores the API host (api.controleonline.com);
|
|
114
|
+
* using it produces broken URLs like:
|
|
115
|
+
* https://api.controleonline.com/api.controleonline.com/files/8408/download
|
|
116
|
+
* Tenant white-labels need:
|
|
117
|
+
* https://api.controleonline.com/erp.jaguncos.com.br/files/8408/download
|
|
118
|
+
* app-community#1160
|
|
119
|
+
*/
|
|
120
|
+
const isApiEntrypointHost = host => {
|
|
121
|
+
const normalized = normalizeText(host).toLowerCase();
|
|
122
|
+
if (!normalized) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const apiHost = resolveApiEntrypointHost();
|
|
127
|
+
if (apiHost && normalized === apiHost) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
normalized === 'api.controleonline.com' ||
|
|
133
|
+
normalized === 'api.controleonline.com.br'
|
|
134
|
+
) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Generic api.* hosts are infrastructure, not tenant app-domains.
|
|
139
|
+
if (/^api\./i.test(normalized)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
};
|
|
145
|
+
|
|
91
146
|
const isEnvironmentLikeHost = host => {
|
|
92
147
|
const normalized = normalizeText(host).toLowerCase();
|
|
93
148
|
if (!normalized) return false;
|
|
@@ -119,6 +174,29 @@ const isEnvironmentLikeHost = host => {
|
|
|
119
174
|
return false;
|
|
120
175
|
};
|
|
121
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Prefer File.domain only when it is a real tenant host.
|
|
179
|
+
* Ignore API entrypoint and environment/platform hosts (app-community#1160).
|
|
180
|
+
*/
|
|
181
|
+
const pickDownloadHost = (fileDomain, fallbackHost) => {
|
|
182
|
+
const candidate = normalizeText(fileDomain);
|
|
183
|
+
if (
|
|
184
|
+
candidate &&
|
|
185
|
+
!isApiEntrypointHost(candidate) &&
|
|
186
|
+
!isEnvironmentLikeHost(candidate)
|
|
187
|
+
) {
|
|
188
|
+
return candidate;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fallback = normalizeText(fallbackHost);
|
|
192
|
+
if (fallback && !isApiEntrypointHost(fallback)) {
|
|
193
|
+
return fallback;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Last resort: runtime host (web multi-tenant) or configured DOMAIN.
|
|
197
|
+
return resolveAppDomain(APP_ENV?.DOMAIN) || fallback;
|
|
198
|
+
};
|
|
199
|
+
|
|
122
200
|
const buildBackendDownloadUrl = (fileId, options = {}) => {
|
|
123
201
|
const normalizedId = String(fileId || '').trim();
|
|
124
202
|
if (!normalizedId) {
|
|
@@ -138,13 +216,11 @@ const buildTenantDownloadUrl = (url, host) => {
|
|
|
138
216
|
return absoluteUrl;
|
|
139
217
|
}
|
|
140
218
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// Do not inject environment hosts into non-download paths (app-community#432).
|
|
147
|
-
if (isEnvironmentLikeHost(normalizedHost) && !isFileDownloadPath) {
|
|
219
|
+
// Do not path-inject environment/platform hosts (app-community#432).
|
|
220
|
+
// Bare /files/{id}/download remains for staging/manager/dev; tenant hosts
|
|
221
|
+
// (erp.jaguncos.com.br, loja..., etc.) still get /{appDomain}/files/... .
|
|
222
|
+
// Never path-inject the API entrypoint host (app-community#1160).
|
|
223
|
+
if (isEnvironmentLikeHost(normalizedHost) || isApiEntrypointHost(normalizedHost)) {
|
|
148
224
|
return absoluteUrl;
|
|
149
225
|
}
|
|
150
226
|
|
|
@@ -227,7 +303,7 @@ export const extractFileId = file => {
|
|
|
227
303
|
return null;
|
|
228
304
|
};
|
|
229
305
|
|
|
230
|
-
|
|
306
|
+
const resolveFileSource = (
|
|
231
307
|
file,
|
|
232
308
|
{company = null, appDomain = '', headers = {}} = {},
|
|
233
309
|
) => {
|
|
@@ -309,9 +385,9 @@ export const resolveDefaultFileSource = (
|
|
|
309
385
|
return null;
|
|
310
386
|
}
|
|
311
387
|
|
|
312
|
-
// Prefer FileService domain
|
|
313
|
-
|
|
314
|
-
const downloadHost =
|
|
388
|
+
// Prefer FileService domain only when it is a tenant host, never the API host.
|
|
389
|
+
// file.domain often comes as api.controleonline.com (app-community#1160).
|
|
390
|
+
const downloadHost = pickDownloadHost(normalizedFile?.domain, host);
|
|
315
391
|
|
|
316
392
|
const uri = isBackendDownload
|
|
317
393
|
? buildTenantDownloadUrl(uriBase, downloadHost)
|
|
@@ -329,6 +405,11 @@ export const resolveDefaultFileSource = (
|
|
|
329
405
|
return null;
|
|
330
406
|
};
|
|
331
407
|
|
|
408
|
+
export const resolveDefaultFileSource = (file, options = {}) => {
|
|
409
|
+
const source = resolveFileSource(file, options);
|
|
410
|
+
return source ? {...source, headers: filterApiHeaders(source.uri, source.headers, APP_ENV?.API_ENTRYPOINT)} : null;
|
|
411
|
+
};
|
|
412
|
+
|
|
332
413
|
export const resolveDefaultFileUrl = (file, options = {}) =>
|
|
333
414
|
resolveDefaultFileSource(file, options)?.uri || '';
|
|
334
415
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import eventBus from '../components/EventBus';
|
|
2
|
+
|
|
3
|
+
const key = 'product-catalog-cache-revision';
|
|
4
|
+
let revision = '0';
|
|
5
|
+
let sequence = 0;
|
|
6
|
+
export const getProductCatalogCacheRevision = () => {
|
|
7
|
+
try { return localStorage.getItem(key) || revision; } catch { return revision; }
|
|
8
|
+
};
|
|
9
|
+
export const invalidateProductCatalogCache = () => {
|
|
10
|
+
revision = `${Date.now()}:${++sequence}`;
|
|
11
|
+
try { localStorage.setItem(key, revision); } catch { /* In-memory invalidation still applies. */ }
|
|
12
|
+
eventBus.emit('product-catalog-cache:cleared');
|
|
13
|
+
};
|
|
@@ -12,6 +12,8 @@ jest.mock('react-native', () => ({
|
|
|
12
12
|
View: props => React.createElement('View', props, props.children),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
|
+
jest.mock('@env', () => ({env: {API_ENTRYPOINT: 'https://api.controleonline.com', DOMAIN: 'manager.controleonline.com'}}));
|
|
16
|
+
|
|
15
17
|
const UserAvatar = require('../../../react/components/UserAvatar').default;
|
|
16
18
|
|
|
17
19
|
const {describe, expect, it} = global;
|
|
@@ -54,3 +56,31 @@ describe('UserAvatar', () => {
|
|
|
54
56
|
expect(image.props.source.uri).toContain('d=404');
|
|
55
57
|
});
|
|
56
58
|
});
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
describe('avatar credential boundary', () => {
|
|
62
|
+
it('does not authenticate an external download-shaped image on web', async () => {
|
|
63
|
+
const {Platform} = require('react-native');
|
|
64
|
+
const previousStorage = global.localStorage;
|
|
65
|
+
const previousFetch = global.fetch;
|
|
66
|
+
Platform.OS = 'web';
|
|
67
|
+
global.localStorage = {getItem: () => JSON.stringify({token: 'session-secret'})};
|
|
68
|
+
global.fetch = jest.fn();
|
|
69
|
+
let tree;
|
|
70
|
+
try {
|
|
71
|
+
await renderer.act(async () => { tree = renderer.create(React.createElement(UserAvatar, {imageUrl: 'https://images.example.test/files/1/download', useGravatar: false})); });
|
|
72
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
73
|
+
expect(tree.root.findByType('Image').props.source.uri).toBe('https://images.example.test/files/1/download');
|
|
74
|
+
} finally {
|
|
75
|
+
if (tree) await renderer.act(async () => tree.unmount());
|
|
76
|
+
Platform.OS = 'ios'; global.localStorage = previousStorage; global.fetch = previousFetch;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
it('removes session headers from external native avatars', async () => {
|
|
80
|
+
let tree;
|
|
81
|
+
try {
|
|
82
|
+
await renderer.act(async () => { tree = renderer.create(React.createElement(UserAvatar, {imageUrl: 'https://images.example.test/avatar.png', imageHeaders: {'API-TOKEN': 'session-secret', Accept: 'image/*'}, useGravatar: false})); });
|
|
83
|
+
expect(tree.root.findByType('Image').props.source.headers).toEqual({Accept: 'image/*'});
|
|
84
|
+
} finally { if (tree) await renderer.act(async () => tree.unmount()); }
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -60,7 +60,7 @@ describe('fileUrl helpers', () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
it('
|
|
63
|
+
it('keeps the explicit environment domain on the registered download route', () => {
|
|
64
64
|
const source = resolveDefaultFileSource(
|
|
65
65
|
{
|
|
66
66
|
id: 8444,
|
|
@@ -77,24 +77,100 @@ describe('fileUrl helpers', () => {
|
|
|
77
77
|
);
|
|
78
78
|
|
|
79
79
|
expect(source).toEqual({
|
|
80
|
-
uri: 'https://api.controleonline.com/files/8444/download',
|
|
80
|
+
uri: 'https://api.controleonline.com/staging.controleonline.com/files/8444/download',
|
|
81
81
|
headers: {
|
|
82
82
|
Authorization: 'Bearer token',
|
|
83
83
|
'app-domain': 'staging.controleonline.com',
|
|
84
84
|
},
|
|
85
85
|
});
|
|
86
|
-
expect(source.uri).
|
|
86
|
+
expect(source.uri).toContain('staging.controleonline.com/files');
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
it('
|
|
89
|
+
it('uses the configured platform domain on the registered download route', () => {
|
|
90
90
|
// DOMAIN mock is manager.controleonline.com; no company → environment-like
|
|
91
91
|
const source = resolveDefaultFileSource(
|
|
92
92
|
{id: 9, url: '/files/9/download'},
|
|
93
93
|
{headers: {Authorization: 'Bearer t'}},
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
-
expect(source.uri).toBe('https://api.controleonline.com/files/9/download');
|
|
96
|
+
expect(source.uri).toBe('https://api.controleonline.com/manager.controleonline.com/files/9/download');
|
|
97
97
|
expect(source.headers['app-domain']).toBe('manager.controleonline.com');
|
|
98
|
-
expect(source.uri).
|
|
98
|
+
expect(source.uri).toMatch(/manager\.controleonline\.com\/files/);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
describe('file authentication origin boundary', () => {
|
|
104
|
+
it('removes ERP credentials from external images and download-shaped URLs', () => {
|
|
105
|
+
for (const uri of ['https://images.example.test/image.png', 'https://images.example.test/files/1/download', '//images.example.test/image.png']) {
|
|
106
|
+
const source = resolveDefaultFileSource(uri, {headers: {'API-TOKEN': 'session-secret', Authorization: 'Bearer session-secret', 'App-Domain': 'tenant.test', Accept: 'image/*'}});
|
|
107
|
+
expect(source.headers).not.toHaveProperty('API-TOKEN');
|
|
108
|
+
expect(source.headers).not.toHaveProperty('Authorization');
|
|
109
|
+
expect(source.headers).not.toHaveProperty('App-Domain');
|
|
110
|
+
expect(source.headers.Accept).toBe('image/*');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
it('preserves authentication on the configured API download', () => {
|
|
114
|
+
const source = resolveDefaultFileSource(1, {headers: {'API-TOKEN': 'session-secret'}});
|
|
115
|
+
expect(source.headers['API-TOKEN']).toBe('session-secret');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('ignores file.domain when it is the API host and uses tenant app-domain (app-community#1160)', () => {
|
|
119
|
+
const company = {domain: 'loja.jaguncos.com.br'};
|
|
120
|
+
|
|
121
|
+
const source = resolveDefaultFileSource(
|
|
122
|
+
{
|
|
123
|
+
id: 8408,
|
|
124
|
+
domain: 'api.controleonline.com',
|
|
125
|
+
url: '/api.controleonline.com/files/8408/download',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
company,
|
|
129
|
+
headers: {Authorization: 'Bearer token'},
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(source.uri).toBe(
|
|
134
|
+
'https://api.controleonline.com/loja.jaguncos.com.br/files/8408/download',
|
|
135
|
+
);
|
|
136
|
+
expect(source.uri).not.toMatch(/api\.controleonline\.com\/files/);
|
|
137
|
+
expect(source.headers['app-domain']).toBe('loja.jaguncos.com.br');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('ignores API host as company domain and falls back to explicit appDomain', () => {
|
|
141
|
+
const source = resolveDefaultFileSource(
|
|
142
|
+
{
|
|
143
|
+
id: 8408,
|
|
144
|
+
domain: 'api.controleonline.com',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
company: {domain: 'api.controleonline.com'},
|
|
148
|
+
appDomain: 'erp.jaguncos.com.br',
|
|
149
|
+
headers: {},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(source.uri).toBe(
|
|
154
|
+
'https://api.controleonline.com/erp.jaguncos.com.br/files/8408/download',
|
|
155
|
+
);
|
|
156
|
+
expect(source.headers['app-domain']).toBe('erp.jaguncos.com.br');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('still prefers a real tenant file.domain over company when not API host', () => {
|
|
160
|
+
const source = resolveDefaultFileSource(
|
|
161
|
+
{
|
|
162
|
+
id: 12,
|
|
163
|
+
domain: 'cardapio.jaguncos.com.br',
|
|
164
|
+
url: '/files/12/download',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
company: {domain: 'erp.jaguncos.com.br'},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(source.uri).toBe(
|
|
172
|
+
'https://api.controleonline.com/cardapio.jaguncos.com.br/files/12/download',
|
|
173
|
+
);
|
|
174
|
+
expect(source.headers['app-domain']).toBe('cardapio.jaguncos.com.br');
|
|
99
175
|
});
|
|
100
176
|
});
|